Răsfoiți Sursa

Let the wizard pick a datasource, and let a datasource decline an editor

The wizard now offers where a post type's posts come from: the datastore, which
is posts people write, or any Trog::DataSource::* module found on disk. The
submitted name is checked against what was actually found rather than against a
regex, since it ends up in a sidecar and is require()d later.

A datasource says whether its posts can be edited with an EDITABLE constant,
and the wizard skips generating an editor when it says no. Trog::DataSource::Virt
says no: a guest is built from libvirt every time the page is drawn and has
nothing behind it to save, so a form posting to /post/save would write a real
post into the datastore to sit alongside, and collide with, the synthesized
ones. A datasource that declares nothing is assumed editable, which is what a
post type is unless it has a reason not to be.

guests is regenerated through the wizard as a result, so it is no longer a type
maintained by hand next to a wizard that could not express it. It comes out
with no editor, its datasource and its hypervisors relation recorded, and its
console capture declared private.

Worth noting for anyone regenerating a type: the sidecar is rebuilt from what
was submitted, so a relation that is not resubmitted is dropped. Regenerating
guests without its hypervisors row silently produced a type the datasource
could find no hypervisors for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U3k2hhSZpcsoe6uBmhF9nB
George Baugh 2 luni în urmă
părinte
comite
d9bff19d04

+ 11 - 0
lib/Trog/DataSource/Virt.pm

@@ -24,6 +24,17 @@ relations pulled in, instead of listing posts somebody wrote.  Nothing here is
 ever written to the datastore -- the guests are the source of truth, and the
 posts are rebuilt from them on every view.
 
+=head2 EDITABLE
+
+False.  A guest is built from libvirt every time the page is drawn and has
+nothing behind it to save, so a post type backed by this wants no editor: a
+form posting to /post/save would write a real post into the datastore which
+would then sit alongside, and collide with, the synthesized ones.
+
+=cut
+
+use constant EDITABLE => 0;
+
 =head2 A NOTE ON SAFETY
 
 libvirt forbids virDomainScreenshot on a read-only connection, so the read path

+ 78 - 7
lib/Trog/Routes/HTML.pm

@@ -865,6 +865,56 @@ sub _get_data_models {
     die "Could not find tCMS data modules!  Is tCMS in \@INC?";
 }
 
+=head2 _get_datasources
+
+Every Trog::DataSource::* module on disk.
+
+The wizard offers these as somewhere other than the datastore for a post type
+to get its posts from, and post_wizard_save checks a submitted one against this
+list -- the name ends up in a sidecar and is later require()d, so it has to be
+something we found rather than something we were told.
+
+=cut
+
+sub _get_datasources {
+    my %found;
+    foreach my $incdir (@INC) {
+        my $dir = "$incdir/Trog/DataSource";
+        next unless -d $dir;
+        opendir( my $dh, $dir ) or next;
+        $found{"Trog::DataSource::$_"} = 1 foreach map { s/\.pm$//r } grep { /\.pm$/ && -f "$dir/$_" } readdir($dh);
+        closedir $dh;
+    }
+    return [ sort keys(%found) ];
+}
+
+=head2 _datasource_editable($module)
+
+Whether a post type backed by $module should be given an editor.
+
+A datasource says so with an EDITABLE constant.  One that does not say is
+assumed editable, since that is what a post type is unless it has a reason not
+to be.
+
+=cut
+
+sub _datasource_editable ($module) {
+    return 1 unless $module;
+
+    my $modpath = $module;
+    $modpath =~ s{::}{/}g;
+    $modpath .= '.pm';
+
+    local $@;
+    eval { require $modpath; 1 } or do {
+        WARN("Datasource '$module' will not load: $@");
+        return 1;
+    };
+
+    return 1 unless $module->can('EDITABLE');
+    return $module->EDITABLE ? 1 : 0;
+}
+
 =head2 config_save
 
 Implements /config/save route.  Saves what little configuration we actually use to config/main.cfg
@@ -2194,6 +2244,8 @@ sub post_wizard ($query) {
             is_admin          => 1,
             forms             => $forms,
             forms_dir         => Trog::Themes::forms_dir(),
+            datasources       => _get_datasources(),
+            datasource        => _wizard_scalar( $query->{datasource} ),
             checked           => \%checked,
             name              => _wizard_scalar( $query->{name} ),
             title_placeholder => _wizard_scalar( $query->{title_placeholder} ),
@@ -2242,6 +2294,13 @@ sub post_wizard_save ($query) {
     return _wizard_fail( $query, "Post type '$name.tx' already exists.  Tick 'Overwrite' if you meant to replace it." )
       if $overwriting && !$query->{overwrite};
 
+    # Whitelisted against what is actually on disk: this name goes into a
+    # sidecar and is require()d later, so a regex is not enough.
+    my $datasource = _wizard_scalar( $query->{datasource} );
+    if ( $datasource && !grep { $_ eq $datasource } @{ _get_datasources() } ) {
+        return _wizard_fail( $query, "'$datasource' is not a datasource I can find." );
+    }
+
     my $fields = _wizard_fields($query);
 
     # Not up for discussion.  A post with no visibility gets an undef pushed
@@ -2255,11 +2314,11 @@ sub post_wizard_save ($query) {
     my @includes = grep { $query->{$_} } @wizard_include_order;
     my $body_form = _wizard_scalar( $query->{body_form} ) eq 'form_multi.tx' ? 'form_multi.tx' : 'form_common.tx';
 
-    my $spec = _wizard_sidecar( $name, $fields, \@includes, $body_form );
+    my $spec = _wizard_sidecar( $name, $fields, \@includes, $body_form, $datasource );
 
     local $@;
     eval {
-        Path::Tiny::path($tx_file)->spew_utf8( _wizard_template( $query, $fields, \@includes, $body_form ) );
+        Path::Tiny::path($tx_file)->spew_utf8( _wizard_template( $query, $fields, \@includes, $body_form, $datasource ) );
         Path::Tiny::path($json_file)->spew_utf8( JSON::MaybeXS->new( pretty => 1, canonical => 1 )->encode($spec) );
         1;
     } or return _wizard_fail( $query, "Failed to write post type '$name': $@" );
@@ -2393,7 +2452,7 @@ Trog::DataModule::schema_for() merges this over the base post schema.
 
 =cut
 
-sub _wizard_sidecar ( $name, $fields, $includes, $body_form ) {
+sub _wizard_sidecar ( $name, $fields, $includes, $body_form, $datasource = '' ) {
     my %properties;
     my @required;
 
@@ -2440,8 +2499,9 @@ sub _wizard_sidecar ( $name, $fields, $includes, $body_form ) {
         type       => 'object',
         properties => \%properties,
     );
-    $spec{required}            = \@required  if @required;
-    $spec{'x-tcms-relations'} = \%relations if %relations;
+    $spec{required}             = \@required   if @required;
+    $spec{'x-tcms-relations'}  = \%relations  if %relations;
+    $spec{'x-tcms-datasource'} = $datasource if $datasource;
 
     return \%spec;
 }
@@ -2477,7 +2537,7 @@ a display half guarded by !$post.addpost, and an edit half guarded by $can_edit.
 
 =cut
 
-sub _wizard_template ( $query, $fields, $includes, $body_form ) {
+sub _wizard_template ( $query, $fields, $includes, $body_form, $datasource = '' ) {
     my $placeholder = _kolon_safe( $query->{title_placeholder} ) || 'Iowa Man Destroys Moon';
 
     # NOT escaped, on purpose.  Letting an admin write template code is the
@@ -2496,7 +2556,18 @@ sub _wizard_template ( $query, $fields, $includes, $body_form ) {
     $out .= qq|        : include "post_title.tx";\n| if $query->{inc_post_title};
     $out .= qq|        : include "post_tags.tx";\n|  if $query->{inc_post_tags};
     $out .= "$display\n"                             if $display;
-    $out .= qq|    : }\n\n|;
+    $out .= qq|    : }\n|;
+
+    # A datasource can say its posts are built rather than written, in which
+    # case an editor would be a form saving something nobody can edit -- and
+    # for Trog::DataSource::Virt, one that writes a real post to sit alongside
+    # the synthesized ones and collide with them.
+    if ( !_datasource_editable($datasource) ) {
+        $out .= qq|</div>\n| if $query->{wrapper};
+        return $out;
+    }
+
+    $out .= qq|\n|;
     $out .= qq|    : if ( \$can_edit ) {\n|;
     $out .= qq|        <div class="postedit">\n|;
     $out .= qq|        : include "edit_head.tx";\n|;

+ 39 - 0
t/Trog-DataSource-Virt.t

@@ -300,6 +300,45 @@ subtest 'guests show nothing an unprivileged viewer cannot use' => sub {
     }
 };
 
+subtest 'a datasource says whether its posts can be edited' => sub {
+    require Trog::Routes::HTML;
+
+    my $sources = Trog::Routes::HTML::_get_datasources();
+    ok( ( grep { $_ eq 'Trog::DataSource::Virt' } @$sources ), 'the wizard can find this datasource' );
+    ok( !( grep { !m/^Trog::DataSource::\w+$/ } @$sources ), 'and finds nothing that is not one' );
+
+    is( Trog::DataSource::Virt->EDITABLE, 0, 'a guest cannot be edited' );
+    is( Trog::Routes::HTML::_datasource_editable('Trog::DataSource::Virt'), 0, 'so no editor is appropriate' );
+
+    # The datastore is the default, and posts in it are written by people.
+    is( Trog::Routes::HTML::_datasource_editable(''), 1, 'the datastore is editable' );
+
+    # A datasource that says nothing is assumed editable, since that is what a
+    # post type is unless it has a reason not to be.
+    {
+        no warnings qw{once};
+        $INC{'Trog/DataSource/SpecMute.pm'} = 1;
+        @Trog::DataSource::SpecMute::ISA = ();
+    }
+    is( Trog::Routes::HTML::_datasource_editable('Trog::DataSource::SpecMute'), 1, 'one that declares nothing is assumed editable' );
+};
+
+subtest 'the guests type is built by the wizard, from a datasource' => sub {
+    my $sidecar = JSON::MaybeXS::decode_json(
+        Path::Tiny->new("$FindBin::Bin/../www/templates/html/components/forms/guests.json")->slurp_utf8 );
+
+    is( $sidecar->{'x-tcms-datasource'}, 'Trog::DataSource::Virt', 'it names its datasource' );
+    ok( $sidecar->{'x-tcms-post-type'}{generated}, 'and is wizard generated rather than hand written' );
+
+    # The relation is what tells the datasource which hypervisors to ask.
+    is_deeply( $sidecar->{'x-tcms-relations'}, { hypervisors => { form => 'hypervisor.tx' } },
+        'and keeps the relation the datasource reads' );
+
+    # No editor: a guest is built on every view and has nothing to save.
+    my $form = Path::Tiny->new("$FindBin::Bin/../www/templates/html/components/forms/guests.tx")->slurp_utf8;
+    unlike( $form, qr{action="/post/save"}, 'the template offers no editor' );
+};
+
 subtest 'the guests type declares its console capture private' => sub {
     require Trog::DataModule;
 

+ 56 - 0
t/Trog-Routes-HTML-series-lifecycle.t

@@ -449,6 +449,62 @@ subtest 'the post wizard invents a new post type' => sub {
     }
 
     like( $body, qr/name="param_private"/, 'and it offers per-field privacy' );
+    like( $body, qr/name="datasource"/,    'and a datasource to draw the posts from' );
+    like( $body, qr/Trog::DataSource::Virt/, 'listing the ones that exist' );
+};
+
+subtest 'a datasource-backed type gets no editor' => sub {
+
+    # A datasource whose posts are built rather than written has nothing behind
+    # a form to save, so generating one would produce an editor that writes
+    # real posts to sit alongside the synthesized ones.
+    my ( $code, $body, $err ) = _render(
+        _admin(
+            route          => '/admin/wyzzerdd/save',
+            method         => 'POST',
+            name           => 'spec_sourced',
+            datasource     => 'Trog::DataSource::Virt',
+            body_form      => 'form_common.tx',
+            wrapper        => 1,
+            inc_post_title => 1,
+            display        => q{<div class="s"><: $post.title :></div>},
+        ),
+        \&Trog::Routes::HTML::post_wizard_save,
+    );
+    is( $code, 200, 'the type was created' ) or diag($err);
+
+    my $generated = Path::Tiny->new('www/templates/html/components/forms/spec_sourced.tx')->slurp_utf8;
+    unlike( $generated, qr{action="/post/save"}, 'no editor was generated' );
+    unlike( $generated, qr/\$can_edit/,          'and nothing guards one' );
+    like( $generated, qr/class="s"/, 'but the display half is there' );
+
+    my $sidecar = JSON::MaybeXS::decode_json(
+        Path::Tiny->new('www/templates/html/components/forms/spec_sourced.json')->slurp_utf8 );
+    is( $sidecar->{'x-tcms-datasource'}, 'Trog::DataSource::Virt', 'and the sidecar records the datasource' );
+
+    # The same type without one keeps its editor.
+    _render(
+        _admin(
+            route          => '/admin/wyzzerdd/save',
+            method         => 'POST',
+            name           => 'spec_stored',
+            body_form      => 'form_common.tx',
+            wrapper        => 1,
+            inc_post_title => 1,
+            display        => q{<div class="s"><: $post.title :></div>},
+        ),
+        \&Trog::Routes::HTML::post_wizard_save,
+    );
+    my $stored = Path::Tiny->new('www/templates/html/components/forms/spec_stored.tx')->slurp_utf8;
+    like( $stored, qr{action="/post/save"}, 'a type on the datastore still gets one' );
+
+    # The name is require()d later, so it has to be one we found.
+    ( $code, $body ) = _render(
+        _admin( route => '/admin/wyzzerdd/save', method => 'POST', name => 'spec_bogus', datasource => 'Evil::Module' ),
+        \&Trog::Routes::HTML::post_wizard_save,
+    );
+    like( $body, qr/not a datasource I can find/, 'a datasource we did not find is refused' );
+    ok( !-e 'www/templates/html/components/forms/spec_bogus.tx', 'and nothing was written' );
 };
 
 subtest 'a field can be declared editors-only' => sub {

+ 20 - 14
www/templates/html/components/forms/guests.json

@@ -1,21 +1,27 @@
 {
-   "properties": {
-      "preview": {
-         "type": "string",
-         "x-tcms-label": "Console capture",
-         "x-tcms-private": true
+   "properties" : {
+      "preview" : {
+         "type" : "string",
+         "x-tcms-input" : "text",
+         "x-tcms-label" : "Console capture",
+         "x-tcms-private" : true
       }
    },
-   "type": "object",
-   "x-tcms-datasource": "Trog::DataSource::Virt",
-   "x-tcms-post-type": {
-      "description": "A libvirt guest.  Not written by anyone -- built from the hypervisors this series' relations pull in, and rebuilt on every view.",
-      "generated": false,
-      "name": "guests"
+   "type" : "object",
+   "x-tcms-datasource" : "Trog::DataSource::Virt",
+   "x-tcms-post-type" : {
+      "body_form" : "form_common.tx",
+      "generated" : true,
+      "generator" : "Trog::Routes::HTML::post_wizard_save",
+      "includes" : [
+         "visibility.tx",
+         "acls.tx"
+      ],
+      "name" : "guests"
    },
-   "x-tcms-relations": {
-      "hypervisors": {
-         "form": "hypervisor.tx"
+   "x-tcms-relations" : {
+      "hypervisors" : {
+         "form" : "hypervisor.tx"
       }
    }
 }

+ 1 - 0
www/templates/html/components/forms/guests.tx

@@ -1,3 +1,4 @@
+<!-- Generated by the tCMS Post Type Wizard.  Regenerating this type will overwrite any hand edits. -->
 <div class="post <: $style :> <: $tiled ? 'tile' : '' :>">
     : if ( !$post.addpost ) {
         : if ( $post.unreachable ) {

+ 13 - 0
www/templates/html/components/post_wizard.tx

@@ -43,6 +43,19 @@ any custom fields, so that the data model knows to keep them when a post is save
     Title placeholder text<br />
     <input class="cooltext" type="text" name="title_placeholder" placeholder="Iowa Man Destroys Moon" value="<: $title_placeholder :>" />
 
+    <br />
+    Where the posts come from<br />
+    <select class="cooltext" name="datasource">
+        <option value="" <: $datasource ? '' : 'selected' :> >The datastore &mdash; posts people write</option>
+: for $datasources -> $source {
+        <option value="<: $source :>" <: $datasource == $source ? 'selected' : '' :> ><: $source :></option>
+: }
+    </select>
+    <br />
+    A datasource builds this type's posts from somewhere else every time the page is drawn, rather
+    than from posts anybody wrote &mdash; and if it says its posts cannot be written, no editor is
+    generated at all, because there would be nothing behind the form to save.
+
     <br />
     Content Body &mdash; this is always the last thing in the editor, and is what carries the post body itself.<br />
     <select class="cooltext" name="body_form">