diff --git a/bots/admin.py b/bots/admin.py index b3e02bff9..6749d802f 100644 --- a/bots/admin.py +++ b/bots/admin.py @@ -16,6 +16,8 @@ FeedbackComment, CHATML_ROLE_ASSISSTANT, SavedRun, + PublishedRun, + PublishedRunVersion, Message, Platform, Feedback, @@ -94,13 +96,14 @@ class BotIntegrationAdmin(admin.ModelAdmin): "updated_at", "billing_account_uid", "saved_run", + "published_run", "analysis_run", ] list_filter = ["platform"] form = BotIntegrationAdminForm - autocomplete_fields = ["saved_run", "analysis_run"] + autocomplete_fields = ["saved_run", "published_run", "analysis_run"] readonly_fields = [ "fb_page_access_token", @@ -120,6 +123,7 @@ class BotIntegrationAdmin(admin.ModelAdmin): "fields": [ "name", "saved_run", + "published_run", "billing_account_uid", "user_language", ], @@ -206,6 +210,38 @@ def view_analysis_results(self, bi: BotIntegration): return html +@admin.register(PublishedRun) +class PublishedRunAdmin(admin.ModelAdmin): + list_display = [ + "__str__", + "published_run_id", + "view_user", + "view_saved_run", + "created_at", + "updated_at", + ] + list_filter = ["workflow"] + search_fields = ["workflow", "published_run_id"] + autocomplete_fields = ["saved_run", "created_by", "last_edited_by"] + readonly_fields = [ + "open_in_gooey", + "created_at", + "updated_at", + ] + + def view_user(self, published_run: PublishedRun): + if published_run.created_by is None: + return None + return change_obj_url(published_run.created_by) + + view_user.short_description = "View User" + + def view_saved_run(self, published_run: PublishedRun): + return change_obj_url(published_run.saved_run) + + view_saved_run.short_description = "View Saved Run" + + @admin.register(SavedRun) class SavedRunAdmin(admin.ModelAdmin): list_display = [ @@ -221,6 +257,7 @@ class SavedRunAdmin(admin.ModelAdmin): ] list_filter = ["workflow"] search_fields = ["workflow", "example_id", "run_id", "uid"] + autocomplete_fields = ["parent_version"] readonly_fields = [ "open_in_gooey", @@ -257,6 +294,12 @@ def preview_input(self, saved_run: SavedRun): return truncate_text_words(BasePage.preview_input(saved_run.state) or "", 100) +@admin.register(PublishedRunVersion) +class PublishedRunVersionAdmin(admin.ModelAdmin): + search_fields = ["id", "version_id", "published_run__published_run_id"] + autocomplete_fields = ["published_run", "saved_run", "changed_by"] + + class LastActiveDeltaFilter(admin.SimpleListFilter): title = Conversation.last_active_delta.short_description parameter_name = Conversation.last_active_delta.__name__ diff --git a/bots/migrations/0049_publishedrun_publishedrunversion.py b/bots/migrations/0049_publishedrun_publishedrunversion.py new file mode 100644 index 000000000..51c209825 --- /dev/null +++ b/bots/migrations/0049_publishedrun_publishedrunversion.py @@ -0,0 +1,265 @@ +# Generated by Django 4.2.7 on 2023-12-05 13:39 + +from django.db import migrations, models +import django.db.models.deletion + +from bots.models import PublishedRunVisibility +from daras_ai_v2.crypto import get_random_doc_id + + +def set_field_attribute(instance, field_name, **attrs): + for field in instance._meta.local_fields: + if field.name == field_name: + for attr, value in attrs.items(): + setattr(field, attr, value) + + +def create_published_run_from_example( + *, + published_run_model, + published_run_version_model, + saved_run, + user, + published_run_id, +): + published_run = published_run_model( + workflow=saved_run.workflow, + published_run_id=published_run_id, + created_by=user, + last_edited_by=user, + saved_run=saved_run, + title=saved_run.page_title, + notes=saved_run.page_notes, + visibility=PublishedRunVisibility.PUBLIC, + is_approved_example=not saved_run.hidden, + ) + set_field_attribute(published_run, "created_at", auto_now_add=False) + set_field_attribute(published_run, "updated_at", auto_now=False) + published_run.created_at = saved_run.created_at + published_run.updated_at = saved_run.updated_at + published_run.save() + set_field_attribute(published_run, "created_at", auto_now_add=True) + set_field_attribute(published_run, "updated_at", auto_now=True) + + version = published_run_version_model( + published_run=published_run, + version_id=get_random_doc_id(), + saved_run=saved_run, + changed_by=user, + title=saved_run.page_title, + notes=saved_run.page_notes, + visibility=PublishedRunVisibility.PUBLIC, + ) + set_field_attribute(published_run, "created_at", auto_now_add=False) + version.created_at = saved_run.updated_at + version.save() + set_field_attribute(published_run, "created_at", auto_now_add=True) + + return published_run + + +def forwards_func(apps, schema_editor): + # if example_id is not null, create published run with + # is_approved_example to True and visibility to Public + saved_run_model = apps.get_model("bots", "SavedRun") + published_run_model = apps.get_model("bots", "PublishedRun") + published_run_version_model = apps.get_model("bots", "PublishedRunVersion") + db_alias = schema_editor.connection.alias + + # all examples + for saved_run in saved_run_model.objects.using(db_alias).filter( + example_id__isnull=False, + ): + create_published_run_from_example( + published_run_model=published_run_model, + published_run_version_model=published_run_version_model, + saved_run=saved_run, + user=None, # TODO: use gooey-support user instead? + published_run_id=saved_run.example_id, + ) + + # recipe root examples + for saved_run in saved_run_model.objects.using(db_alias).filter( + example_id__isnull=True, + run_id__isnull=True, + uid__isnull=True, + ): + create_published_run_from_example( + published_run_model=published_run_model, + published_run_version_model=published_run_version_model, + saved_run=saved_run, + user=None, + published_run_id="", + ) + + +def backwards_func(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + dependencies = [ + ("app_users", "0010_alter_appuser_balance_alter_appuser_created_at_and_more"), + ("bots", "0048_alter_messageattachment_url"), + ] + + operations = [ + migrations.CreateModel( + name="PublishedRun", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("published_run_id", models.CharField(blank=True, max_length=128)), + ( + "workflow", + models.IntegerField( + choices=[ + (1, "Doc Search"), + (2, "Doc Summary"), + (3, "Google GPT"), + (4, "Copilot"), + (5, "Lipysnc + TTS"), + (6, "Text to Speech"), + (7, "Speech Recognition"), + (8, "Lipsync"), + (9, "Deforum Animation"), + (10, "Compare Text2Img"), + (11, "Text2Audio"), + (12, "Img2Img"), + (13, "Face Inpainting"), + (14, "Google Image Gen"), + (15, "Compare AI Upscalers"), + (16, "SEO Summary"), + (17, "Email Face Inpainting"), + (18, "Social Lookup Email"), + (19, "Object Inpainting"), + (20, "Image Segmentation"), + (21, "Compare LLM"), + (22, "Chyron Plant"), + (23, "Letter Writer"), + (24, "Smart GPT"), + (25, "AI QR Code"), + (26, "Doc Extract"), + (27, "Related QnA Maker"), + (28, "Related QnA Maker Doc"), + (29, "Embeddings"), + (30, "Bulk Runner"), + ] + ), + ), + ("title", models.TextField(blank=True, default="")), + ("notes", models.TextField(blank=True, default="")), + ( + "visibility", + models.IntegerField( + choices=[(1, "Unlisted"), (2, "Public")], default=1 + ), + ), + ("is_approved_example", models.BooleanField(default=False)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="published_runs", + to="app_users.appuser", + ), + ), + ( + "last_edited_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="app_users.appuser", + ), + ), + ( + "saved_run", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="published_runs", + to="bots.savedrun", + ), + ), + ], + options={ + "ordering": ["-updated_at"], + "unique_together": {("workflow", "published_run_id")}, + }, + ), + migrations.CreateModel( + name="PublishedRunVersion", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("version_id", models.CharField(max_length=128, unique=True)), + ("title", models.TextField(blank=True, default="")), + ("notes", models.TextField(blank=True, default="")), + ( + "visibility", + models.IntegerField( + choices=[(1, "Unlisted"), (2, "Public")], default=1 + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "changed_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="app_users.appuser", + ), + ), + ( + "published_run", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="versions", + to="bots.publishedrun", + ), + ), + ( + "saved_run", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="published_run_versions", + to="bots.savedrun", + ), + ), + ], + options={ + "ordering": ["-created_at"], + "get_latest_by": "created_at", + "indexes": [ + models.Index( + fields=["published_run", "-created_at"], + name="bots_publis_publish_9cd246_idx", + ), + models.Index( + fields=["version_id"], name="bots_publis_version_c121d4_idx" + ), + ], + }, + ), + migrations.RunPython( + forwards_func, + backwards_func, + ), + ] diff --git a/bots/migrations/0050_botintegration_published_run.py b/bots/migrations/0050_botintegration_published_run.py new file mode 100644 index 000000000..2b8e2035d --- /dev/null +++ b/bots/migrations/0050_botintegration_published_run.py @@ -0,0 +1,26 @@ +# Generated by Django 4.2.7 on 2023-12-05 13:39 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("bots", "0049_publishedrun_publishedrunversion"), + ] + + operations = [ + migrations.AddField( + model_name="botintegration", + name="published_run", + field=models.ForeignKey( + blank=True, + default=None, + help_text="The saved run that the bot is based on", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="botintegrations", + to="bots.publishedrun", + ), + ), + ] diff --git a/bots/migrations/0051_savedrun_parent_version.py b/bots/migrations/0051_savedrun_parent_version.py new file mode 100644 index 000000000..3c2c16b18 --- /dev/null +++ b/bots/migrations/0051_savedrun_parent_version.py @@ -0,0 +1,24 @@ +# Generated by Django 4.2.7 on 2023-12-08 10:57 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("bots", "0050_botintegration_published_run"), + ] + + operations = [ + migrations.AddField( + model_name="savedrun", + name="parent_version", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="children_runs", + to="bots.publishedrunversion", + ), + ), + ] diff --git a/bots/migrations/0052_alter_publishedrun_options_and_more.py b/bots/migrations/0052_alter_publishedrun_options_and_more.py new file mode 100644 index 000000000..4d6dfe2f8 --- /dev/null +++ b/bots/migrations/0052_alter_publishedrun_options_and_more.py @@ -0,0 +1,42 @@ +# Generated by Django 4.2.7 on 2023-12-11 05:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("bots", "0051_savedrun_parent_version"), + ] + + operations = [ + migrations.AlterModelOptions( + name="publishedrun", + options={"get_latest_by": "updated_at", "ordering": ["-updated_at"]}, + ), + migrations.AddIndex( + model_name="publishedrun", + index=models.Index( + fields=["workflow"], name="bots_publis_workflo_a0953a_idx" + ), + ), + migrations.AddIndex( + model_name="publishedrun", + index=models.Index( + fields=["workflow", "created_by"], name="bots_publis_workflo_c75a55_idx" + ), + ), + migrations.AddIndex( + model_name="publishedrun", + index=models.Index( + fields=["workflow", "published_run_id"], + name="bots_publis_workflo_87bece_idx", + ), + ), + migrations.AddIndex( + model_name="publishedrun", + index=models.Index( + fields=["workflow", "visibility", "is_approved_example"], + name="bots_publis_workflo_36a83a_idx", + ), + ), + ] diff --git a/bots/migrations/0053_alter_publishedrun_workflow_alter_savedrun_workflow_and_more.py b/bots/migrations/0053_alter_publishedrun_workflow_alter_savedrun_workflow_and_more.py new file mode 100644 index 000000000..4398af91d --- /dev/null +++ b/bots/migrations/0053_alter_publishedrun_workflow_alter_savedrun_workflow_and_more.py @@ -0,0 +1,103 @@ +# Generated by Django 4.2.7 on 2023-12-21 15:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("bots", "0052_alter_publishedrun_options_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="publishedrun", + name="workflow", + field=models.IntegerField( + choices=[ + (1, "Doc Search"), + (2, "Doc Summary"), + (3, "Google GPT"), + (4, "Copilot"), + (5, "Lipysnc + TTS"), + (6, "Text to Speech"), + (7, "Speech Recognition"), + (8, "Lipsync"), + (9, "Deforum Animation"), + (10, "Compare Text2Img"), + (11, "Text2Audio"), + (12, "Img2Img"), + (13, "Face Inpainting"), + (14, "Google Image Gen"), + (15, "Compare AI Upscalers"), + (16, "SEO Summary"), + (17, "Email Face Inpainting"), + (18, "Social Lookup Email"), + (19, "Object Inpainting"), + (20, "Image Segmentation"), + (21, "Compare LLM"), + (22, "Chyron Plant"), + (23, "Letter Writer"), + (24, "Smart GPT"), + (25, "AI QR Code"), + (26, "Doc Extract"), + (27, "Related QnA Maker"), + (28, "Related QnA Maker Doc"), + (29, "Embeddings"), + (30, "Bulk Runner"), + (31, "Bulk Evaluator"), + ] + ), + ), + migrations.AlterField( + model_name="savedrun", + name="workflow", + field=models.IntegerField( + choices=[ + (1, "Doc Search"), + (2, "Doc Summary"), + (3, "Google GPT"), + (4, "Copilot"), + (5, "Lipysnc + TTS"), + (6, "Text to Speech"), + (7, "Speech Recognition"), + (8, "Lipsync"), + (9, "Deforum Animation"), + (10, "Compare Text2Img"), + (11, "Text2Audio"), + (12, "Img2Img"), + (13, "Face Inpainting"), + (14, "Google Image Gen"), + (15, "Compare AI Upscalers"), + (16, "SEO Summary"), + (17, "Email Face Inpainting"), + (18, "Social Lookup Email"), + (19, "Object Inpainting"), + (20, "Image Segmentation"), + (21, "Compare LLM"), + (22, "Chyron Plant"), + (23, "Letter Writer"), + (24, "Smart GPT"), + (25, "AI QR Code"), + (26, "Doc Extract"), + (27, "Related QnA Maker"), + (28, "Related QnA Maker Doc"), + (29, "Embeddings"), + (30, "Bulk Runner"), + (31, "Bulk Evaluator"), + ], + default=4, + ), + ), + migrations.AddIndex( + model_name="publishedrun", + index=models.Index( + fields=[ + "workflow", + "visibility", + "is_approved_example", + "published_run_id", + ], + name="bots_publis_workflo_d3ad4e_idx", + ), + ), + ] diff --git a/bots/models.py b/bots/models.py index 2804d2fce..1968569c1 100644 --- a/bots/models.py +++ b/bots/models.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import datetime import typing from multiprocessing.pool import ThreadPool @@ -16,6 +18,7 @@ from bots.admin_links import open_in_new_tab from bots.custom_fields import PostgresJSONEncoder, CustomURLField from daras_ai_v2.language_model import format_chat_entry +from daras_ai_v2.crypto import get_random_doc_id if typing.TYPE_CHECKING: from daras_ai_v2.base import BasePage @@ -28,6 +31,20 @@ EPOCH = datetime.datetime.utcfromtimestamp(0) +class PublishedRunVisibility(models.IntegerChoices): + UNLISTED = 1 + PUBLIC = 2 + + def help_text(self): + match self: + case PublishedRunVisibility.UNLISTED: + return "Only me + people with a link" + case PublishedRunVisibility.PUBLIC: + return "Public" + case _: + return self.label + + class Platform(models.IntegerChoices): FACEBOOK = 1 INSTAGRAM = (2, "Instagram & FB") @@ -124,6 +141,13 @@ class SavedRun(models.Model): blank=True, related_name="children", ) + parent_version = models.ForeignKey( + "bots.PublishedRunVersion", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="children_runs", + ) workflow = models.IntegerField( choices=Workflow.choices, default=Workflow.VIDEO_BOTS @@ -202,10 +226,6 @@ def to_dict(self) -> dict: ret[StateKeys.run_time] = self.run_time.total_seconds() if self.run_status: ret[StateKeys.run_status] = self.run_status - if self.page_title: - ret[StateKeys.page_title] = self.page_title - if self.page_notes: - ret[StateKeys.page_notes] = self.page_notes if self.hidden: ret[StateKeys.hidden] = self.hidden if self.is_flagged: @@ -236,9 +256,6 @@ def copy_from_firebase_state(self, state: dict) -> "SavedRun": seconds=state.pop(StateKeys.run_time, None) or 0 ) self.run_status = state.pop(StateKeys.run_status, None) or "" - self.page_title = state.pop(StateKeys.page_title, None) or "" - self.page_notes = state.pop(StateKeys.page_notes, None) or "" - # self.hidden = state.pop(StateKeys.hidden, False) self.is_flagged = state.pop("is_flagged", False) self.state = state @@ -267,6 +284,12 @@ def submit_api_call( ) return result, page.run_doc_sr(run_id, uid) + def get_creator(self) -> AppUser | None: + if self.uid: + return AppUser.objects.filter(uid=self.uid).first() + else: + return None + @admin.display(description="Open in Gooey") def open_in_gooey(self): return open_in_new_tab(self.get_app_url(), label=self.get_app_url()) @@ -284,7 +307,7 @@ class BotIntegrationQuerySet(models.QuerySet): @transaction.atomic() def reset_fb_pages_for_user( self, uid: str, fb_pages: list[dict] - ) -> list["BotIntegration"]: + ) -> list[BotIntegration]: saved = [] for fb_page in fb_pages: fb_page_id = fb_page["id"] @@ -339,6 +362,15 @@ class BotIntegration(models.Model): blank=True, help_text="The saved run that the bot is based on", ) + published_run = models.ForeignKey( + "bots.PublishedRun", + on_delete=models.SET_NULL, + related_name="botintegrations", + null=True, + default=None, + blank=True, + help_text="The saved run that the bot is based on", + ) billing_account_uid = models.TextField( help_text="The gooey account uid where the credits will be deducted from", db_index=True, @@ -479,6 +511,14 @@ def __str__(self): else: return self.name or platform_name + def get_active_saved_run(self) -> SavedRun | None: + if self.published_run: + return self.published_run.saved_run + elif self.saved_run: + return self.saved_run + else: + return None + def get_display_name(self): return ( (self.wa_phone_number and self.wa_phone_number.as_international) @@ -940,3 +980,204 @@ class FeedbackComment(models.Model): author = models.ForeignKey(get_user_model(), on_delete=models.CASCADE) comment = models.TextField() created_at = models.DateTimeField(auto_now_add=True) + + +class PublishedRun(models.Model): + # published_run_id was earlier SavedRun.example_id + published_run_id = models.CharField( + max_length=128, + blank=True, + ) + + saved_run = models.ForeignKey( + "bots.SavedRun", + on_delete=models.PROTECT, + related_name="published_runs", + null=True, + ) + workflow = models.IntegerField( + choices=Workflow.choices, + ) + title = models.TextField(blank=True, default="") + notes = models.TextField(blank=True, default="") + visibility = models.IntegerField( + choices=PublishedRunVisibility.choices, + default=PublishedRunVisibility.UNLISTED, + ) + is_approved_example = models.BooleanField(default=False) + + created_by = models.ForeignKey( + "app_users.AppUser", + on_delete=models.SET_NULL, # TODO: set to sentinel instead (e.g. github's ghost user) + null=True, + related_name="published_runs", + ) + last_edited_by = models.ForeignKey( + "app_users.AppUser", + on_delete=models.SET_NULL, # TODO: set to sentinel instead (e.g. github's ghost user) + null=True, + ) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + get_latest_by = "updated_at" + + ordering = ["-updated_at"] + unique_together = [ + ["workflow", "published_run_id"], + ] + + indexes = [ + models.Index(fields=["workflow"]), + models.Index(fields=["workflow", "created_by"]), + models.Index(fields=["workflow", "published_run_id"]), + models.Index(fields=["workflow", "visibility", "is_approved_example"]), + models.Index( + fields=[ + "workflow", + "visibility", + "is_approved_example", + "published_run_id", + ] + ), + ] + + def __str__(self): + return self.get_app_url() + + @admin.display(description="Open in Gooey") + def open_in_gooey(self): + return open_in_new_tab(self.get_app_url(), label=self.get_app_url()) + + @classmethod + def create_published_run( + cls, + *, + workflow: Workflow, + published_run_id: str, + saved_run: SavedRun, + user: AppUser, + title: str, + notes: str, + visibility: PublishedRunVisibility, + ): + with transaction.atomic(): + published_run = PublishedRun( + workflow=workflow, + published_run_id=published_run_id, + created_by=user, + last_edited_by=user, + title=title, + ) + published_run.save() + published_run.add_version( + user=user, + saved_run=saved_run, + title=title, + visibility=visibility, + notes=notes, + ) + return published_run + + def duplicate( + self, + *, + user: AppUser, + title: str, + notes: str, + visibility: PublishedRunVisibility, + ) -> PublishedRun: + return PublishedRun.create_published_run( + workflow=Workflow(self.workflow), + published_run_id=get_random_doc_id(), + saved_run=self.saved_run, + user=user, + title=title, + notes=notes, + visibility=visibility, + ) + + def get_app_url(self): + return Workflow(self.workflow).get_app_url( + example_id=self.published_run_id, run_id="", uid="" + ) + + def add_version( + self, + *, + user: AppUser, + saved_run: SavedRun, + visibility: PublishedRunVisibility, + title: str, + notes: str, + ): + assert saved_run.workflow == self.workflow + + with transaction.atomic(): + version = PublishedRunVersion( + published_run=self, + version_id=get_random_doc_id(), + saved_run=saved_run, + changed_by=user, + title=title, + notes=notes, + visibility=visibility, + ) + version.save() + self.update_fields_to_latest_version() + + def is_editor(self, user: AppUser): + return self.created_by == user + + def is_root_example(self): + return not self.published_run_id + + def update_fields_to_latest_version(self): + latest_version = self.versions.latest() + self.saved_run = latest_version.saved_run + self.last_edited_by = latest_version.changed_by + self.title = latest_version.title + self.notes = latest_version.notes + self.visibility = latest_version.visibility + + self.save() + + +class PublishedRunVersion(models.Model): + version_id = models.CharField(max_length=128, unique=True) + + published_run = models.ForeignKey( + PublishedRun, + on_delete=models.CASCADE, + related_name="versions", + ) + saved_run = models.ForeignKey( + SavedRun, + on_delete=models.PROTECT, + related_name="published_run_versions", + ) + changed_by = models.ForeignKey( + "app_users.AppUser", + on_delete=models.SET_NULL, # TODO: set to sentinel instead (e.g. github's ghost user) + null=True, + ) + title = models.TextField(blank=True, default="") + notes = models.TextField(blank=True, default="") + visibility = models.IntegerField( + choices=PublishedRunVisibility.choices, + default=PublishedRunVisibility.UNLISTED, + ) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["-created_at"] + get_latest_by = "created_at" + indexes = [ + models.Index(fields=["published_run", "-created_at"]), + models.Index(fields=["version_id"]), + ] + + def __str__(self): + return f"{self.published_run} - {self.version_id}" diff --git a/daras_ai_v2/base.py b/daras_ai_v2/base.py index 644e0f04c..3368a36dd 100644 --- a/daras_ai_v2/base.py +++ b/daras_ai_v2/base.py @@ -6,6 +6,8 @@ import urllib.parse import uuid from copy import deepcopy +from enum import Enum +from itertools import pairwise from random import Random from time import sleep from types import SimpleNamespace @@ -14,7 +16,6 @@ import requests import sentry_sdk from django.utils import timezone -from enum import Enum from fastapi import HTTPException from firebase_admin import auth from furl import furl @@ -26,7 +27,13 @@ import gooey_ui as st from app_users.models import AppUser, AppUserTransaction -from bots.models import SavedRun, Workflow +from bots.models import ( + SavedRun, + PublishedRun, + PublishedRunVersion, + PublishedRunVisibility, + Workflow, +) from daras_ai.image_input import truncate_text_words from daras_ai_v2 import settings from daras_ai_v2.api_examples_widget import api_example_generator @@ -54,9 +61,14 @@ ) from daras_ai_v2.send_email import send_reported_run_email from daras_ai_v2.tabs_widget import MenuTabs -from daras_ai_v2.user_date_widgets import render_js_dynamic_dates, js_dynamic_date +from daras_ai_v2.user_date_widgets import ( + render_js_dynamic_dates, + re_render_js_dynamic_dates, + js_dynamic_date, +) from gooey_ui import realtime_clear_subs from gooey_ui.pubsub import realtime_pull +from gooey_ui.components.modal import Modal DEFAULT_META_IMG = ( # Small @@ -80,9 +92,6 @@ class RecipeRunState(Enum): class StateKeys: - page_title = "__title" - page_notes = "__notes" - created_at = "created_at" updated_at = "updated_at" @@ -190,13 +199,76 @@ def render(self): self.render_report_form() return - st.session_state.setdefault(StateKeys.page_title, self.title) - st.session_state.setdefault( - StateKeys.page_notes, self.preview_description(st.session_state) + example_id, run_id, uid = extract_query_params(gooey_get_query_params()) + current_run = self.get_sr_from_query_params(example_id, run_id, uid) + published_run = self.get_current_published_run() + is_root_example = published_run and published_run.is_root_example() + title, breadcrumbs = self._get_title_and_breadcrumbs( + current_run=current_run, + published_run=published_run, ) + with st.div(className="d-flex justify-content-between mt-4"): + with st.div(className="d-lg-flex d-block align-items-center"): + if not breadcrumbs and not self.run_user: + self._render_title(title) + + if breadcrumbs: + with st.tag("div", className="me-3 mb-1 mb-lg-0 py-2 py-lg-0"): + self._render_breadcrumbs(breadcrumbs) + + author = self.run_user or current_run.get_creator() + if not is_root_example: + self.render_author( + author, + show_as_link=self.is_current_user_admin(), + ) + + with st.div(className="d-flex align-items-center"): + is_current_user_creator = ( + self.request + and self.request.user + and current_run.get_creator() == self.request.user + ) + has_unpublished_changes = ( + published_run + and published_run.saved_run != current_run + and self.request + and self.request.user + and published_run.is_editor(self.request.user) + ) + + if is_current_user_creator and has_unpublished_changes: + self._render_unpublished_changes_indicator() + + with st.div(className="d-flex align-items-start right-action-icons"): + st.html( + """ + + """ + ) + + if is_current_user_creator: + self._render_published_run_buttons( + current_run=current_run, + published_run=published_run, + ) - self._render_page_title_with_breadcrumbs(example_id, run_id, uid) - st.write(st.session_state.get(StateKeys.page_notes)) + self._render_social_buttons( + show_button_text=not is_current_user_creator + ) + + with st.div(): + if breadcrumbs or self.run_user: + # only render title here if the above row was not empty + self._render_title(title) + if published_run and published_run.notes: + st.write(published_run.notes) + elif is_root_example: + st.write(self.preview_description(current_run.to_dict())) try: selected_tab = MenuTabs.paths_reverse[self.tab] @@ -213,57 +285,419 @@ def render(self): with st.nav_tab_content(): self.render_selected_tab(selected_tab) - def _render_page_title_with_breadcrumbs( - self, example_id: str, run_id: str, uid: str + def _render_title(self, title: str): + st.write(f"# {title}") + + def _render_unpublished_changes_indicator(self): + with st.div( + className="d-none d-lg-flex h-100 align-items-center text-muted ms-2" + ): + with st.tag("span", className="d-inline-block"): + st.html("Unpublished changes") + + def _render_social_buttons(self, show_button_text: bool = False): + button_text = ( + ' Copy Link' + if show_button_text + else "" + ) + + copy_to_clipboard_button( + f'{button_text}', + value=self._get_current_app_url(), + type="secondary", + className="mb-0 ms-lg-2", + ) + + def _render_published_run_buttons( + self, + *, + current_run: SavedRun, + published_run: PublishedRun | None, ): - if example_id or run_id: - # the title on the saved root / the hardcoded title - recipe_title = ( - self.recipe_doc_sr().to_dict().get(StateKeys.page_title) or self.title + is_update_mode = bool( + published_run + and ( + published_run.is_editor(self.request.user) + or self.is_current_user_admin() ) + ) - # the user saved title for the current run (if its not the same as the recipe title) - current_title = st.session_state.get(StateKeys.page_title) - if current_title == recipe_title: - current_title = "" + with st.div(): + with st.div(className="d-flex justify-content-end"): + st.html( + """ + + """ + ) - # prefer the prompt as h1 title for runs, but not for examples - prompt_title = truncate_text_words( - self.preview_input(st.session_state) or "", maxlen=60 - ).replace("\n", " ") - if run_id: - h1_title = prompt_title or current_title or recipe_title - else: - h1_title = current_title or prompt_title or recipe_title - - # render recipe title if it doesn't clash with the h1 title - render_item1 = recipe_title and recipe_title != h1_title - # render current title if it doesn't clash with the h1 title - render_item2 = current_title and current_title != h1_title - if render_item1 or render_item2: # avoids empty space - with st.breadcrumbs(className="mt-4"): - if render_item1: - st.breadcrumb_item( - recipe_title, - link_to=self.app_url(), - className="text-muted", + run_actions_button = ( + st.button( + '', + className="mb-0 ms-lg-2", + type="tertiary", + ) + if is_update_mode + else None + ) + run_actions_modal = Modal("Options", key="published-run-options-modal") + if run_actions_button: + run_actions_modal.open() + + save_icon = '' + save_text = "Update" if is_update_mode else "Save" + save_button = st.button( + f'{save_icon} {save_text}', + className="mb-0 ms-lg-2 px-lg-4", + type="primary", + ) + publish_modal = Modal("", key="publish-modal") + if save_button: + if self.request.user.is_anonymous: + redirect_url = furl( + "/login", + query_params={ + "next": furl(self.request.url).set(origin=None) + }, ) - if render_item2: - current_sr = self.get_sr_from_query_params( - example_id, run_id, uid + # TODO: investigate why RedirectException does not work here + force_redirect(redirect_url) + return + else: + publish_modal.open() + + if publish_modal.is_open(): + with publish_modal.container( + style={"min-width": "min(500px, 100vw)"} + ): + self._render_publish_modal( + current_run=current_run, + published_run=published_run, + is_update_mode=is_update_mode, ) - st.breadcrumb_item( - current_title, - link_to=current_sr.parent.get_app_url() - if current_sr.parent_id - else None, + + if run_actions_modal.is_open(): + with run_actions_modal.container( + style={"min-width": "min(300px, 100vw)"} + ): + self._render_run_actions_modal( + current_run=current_run, + published_run=published_run, + modal=run_actions_modal, ) - st.write(f"# {h1_title}") + + def _render_publish_modal( + self, + *, + current_run: SavedRun, + published_run: PublishedRun | None, + is_update_mode: bool = False, + ): + if is_update_mode: + assert published_run is not None, "published_run must be set in update mode" + + with st.div(className="visibility-radio"): + st.write("### Publish to") + convert_state_type(st.session_state, "published_run_visibility", int) + if is_update_mode: + st.session_state.setdefault( + "published_run_visibility", published_run.visibility + ) + published_run_visibility = st.radio( + "", + key="published_run_visibility", + options=PublishedRunVisibility.values, + format_func=lambda x: PublishedRunVisibility(x).help_text(), + ) + st.radio( + "", + options=[ + 'Anyone at my org (coming soon)', + ], + disabled=True, + checked_by_default=False, + ) + + with st.div(className="mt-4"): + recipe_title = self.get_root_published_run().title or self.title + default_title = ( + published_run.title + if is_update_mode + else f"{self.request.user.display_name}'s {recipe_title}" + ) + published_run_title = st.text_input( + "Title", + key="published_run_title", + value=default_title, + ) + st.session_state.setdefault( + "published_run_notes", + published_run and published_run.notes or "", + ) + published_run_notes = st.text_area( + "Notes", + key="published_run_notes", + ) + + with st.div(className="mt-4 d-flex justify-content-center"): + save_icon = '' + publish_button = st.button( + f"{save_icon} Save", className="px-4", type="primary" + ) + + if publish_button: + recipe_title = self.get_root_published_run().title or self.title + is_root_published_run = is_update_mode and published_run.is_root_example() + if ( + not is_root_published_run + and published_run_title.strip() == recipe_title.strip() + ): + st.error("Title can't be the same as the recipe title") + return + if not is_update_mode: + published_run = self.create_published_run( + published_run_id=get_random_doc_id(), + saved_run=current_run, + user=self.request.user, + title=published_run_title.strip(), + notes=published_run_notes.strip(), + visibility=PublishedRunVisibility(published_run_visibility), + ) + else: + updates = dict( + saved_run=current_run, + title=published_run_title.strip(), + notes=published_run_notes.strip(), + visibility=PublishedRunVisibility(published_run_visibility), + ) + if self._has_published_run_changed( + published_run=published_run, **updates + ): + published_run.add_version( + user=self.request.user, + **updates, + ) + else: + st.error("No changes to publish") + return + + force_redirect(published_run.get_app_url()) + + def _has_published_run_changed( + self, + *, + published_run: PublishedRun, + saved_run: SavedRun, + title: str, + notes: str, + visibility: PublishedRunVisibility, + ): + return ( + published_run.title != title + or published_run.notes != notes + or published_run.visibility != visibility + or published_run.saved_run != saved_run + ) + + def _render_run_actions_modal( + self, + *, + current_run: SavedRun, + published_run: PublishedRun, + modal: Modal, + ): + assert published_run is not None + + is_latest_version = published_run.saved_run == current_run + + with st.div(className="mt-4"): + duplicate_button = None + save_as_new_button = None + duplicate_icon = save_as_new_icon = '' + if is_latest_version: + duplicate_button = st.button( + f"{duplicate_icon} Duplicate", type="secondary", className="w-100" + ) + else: + save_as_new_button = st.button( + f"{save_as_new_icon} Save as New", + type="secondary", + className="w-100", + ) + delete_icon = '' + delete_button = st.button( + f"{delete_icon} Delete", type="secondary", className="w-100 text-danger" + ) + + if duplicate_button: + duplicate_pr = self.duplicate_published_run( + published_run, + title=f"{published_run.title} (Copy)", + notes=published_run.notes, + visibility=PublishedRunVisibility(PublishedRunVisibility.UNLISTED), + ) + raise QueryParamsRedirectException( + query_params=dict(example_id=duplicate_pr.published_run_id), + ) + + if save_as_new_button: + new_pr = self.create_published_run( + published_run_id=get_random_doc_id(), + saved_run=current_run, + user=self.request.user, + title=f"{published_run.title} (Copy)", + notes=published_run.notes, + visibility=PublishedRunVisibility(PublishedRunVisibility.UNLISTED), + ) + raise QueryParamsRedirectException( + query_params=dict(example_id=new_pr.published_run_id) + ) + + confirm_delete_modal = Modal("Confirm Delete", key="confirm-delete-modal") + if delete_button: + if not published_run.published_run_id: + st.error("Cannot delete root example") + return + confirm_delete_modal.open() + + with st.div(className="mt-4"): + st.write("#### Version History", className="mb-4") + self._render_version_history() + + if confirm_delete_modal.is_open(): + modal.empty() + with confirm_delete_modal.container(): + self._render_confirm_delete_modal( + published_run=published_run, + modal=confirm_delete_modal, + ) + + def _render_confirm_delete_modal( + self, + *, + published_run: PublishedRun, + modal: Modal, + ): + st.write( + "Are you sure you want to delete this published run? " + f"_({published_run.title})_" + ) + st.caption("This will also delete all the associated versions.") + with st.div(className="d-flex"): + confirm_button = st.button( + 'Confirm', + type="secondary", + className="w-100", + ) + cancel_button = st.button( + "Cancel", + type="secondary", + className="w-100", + ) + + if confirm_button: + published_run.delete() + raise QueryParamsRedirectException(query_params={}) + + if cancel_button: + modal.close() + + def _get_title_and_breadcrumbs( + self, + current_run: SavedRun, + published_run: PublishedRun | None, + ) -> tuple[str, list[tuple[str, str | None]]]: + if ( + published_run + and not published_run.published_run_id + and current_run == published_run.saved_run + ): + # when published_run.published_run_id is blank, the run is the root example + return self.get_recipe_title(), [] else: - st.write(f"# {self.get_recipe_title(st.session_state)}") + # the title on the saved root / the hardcoded title + recipe_title = self.get_root_published_run().title or self.title + prompt_title = truncate_text_words( + self.preview_input(current_run.to_dict()) or "", + maxlen=60, + ).replace("\n", " ") - def get_recipe_title(self, state: dict) -> str: - return state.get(StateKeys.page_title) or self.title or self.workflow.label + recipe_breadcrumb = (recipe_title, self.app_url()) + if published_run and current_run == published_run.saved_run: + # recipe root + return published_run.title or prompt_title or recipe_title, [ + recipe_breadcrumb + ] + else: + if not published_run or not published_run.published_run_id: + # run created directly from recipe root + h1_title = prompt_title or f"Run: {recipe_title}" + return h1_title, [recipe_breadcrumb] + else: + h1_title = ( + prompt_title or f"Run: {published_run.title or recipe_title}" + ) + return h1_title, [ + recipe_breadcrumb, + ( + published_run.title + or f"Fork {published_run.published_run_id}", + published_run.get_app_url(), + ), + ] + + def _render_breadcrumbs(self, items: list[tuple[str, str | None]]): + st.html( + """ + + """ + ) + + render_item1 = items and items[0] + render_item2 = items[1:] and items[1] + if render_item1 or render_item2: # avoids empty space + with st.breadcrumbs(): + if render_item1: + text, link = render_item1 + st.breadcrumb_item( + text, + link_to=link, + className="text-muted", + ) + if render_item2: + text, link = render_item2 + st.breadcrumb_item( + text, + link_to=link, + ) + + def get_recipe_title(self) -> str: + return ( + self.get_or_create_root_published_run().title + or self.title + or self.workflow.label + ) def get_explore_image(self, state: dict) -> str: return self.explore_image or "" @@ -281,6 +715,8 @@ def get_tabs(self): tabs = [MenuTabs.run, MenuTabs.examples, MenuTabs.run_as_api] if self.request.user: tabs.extend([MenuTabs.history]) + if self.request.user and not self.request.user.is_anonymous: + tabs.extend([MenuTabs.saved]) return tabs def render_selected_tab(self, selected_tab: str): @@ -301,6 +737,7 @@ def render_selected_tab(self, selected_tab: str): self._render_save_options() self.render_related_workflows() + render_js_dynamic_dates() case MenuTabs.examples: self._examples_tab() @@ -313,6 +750,76 @@ def render_selected_tab(self, selected_tab: str): case MenuTabs.run_as_api: self.run_as_api_tab() + case MenuTabs.saved: + self._saved_tab() + render_js_dynamic_dates() + + def _render_version_history(self): + published_run = self.get_current_published_run() + + if published_run: + versions = published_run.versions.all() + first_version = versions[0] + for version, older_version in pairwise(versions): + first_version = older_version + self._render_version_row(version, older_version) + self._render_version_row(first_version, None) + re_render_js_dynamic_dates() + + def _render_version_row( + self, + version: PublishedRunVersion, + older_version: PublishedRunVersion | None, + ): + st.html( + """ + + """ + ) + url = self.app_url( + example_id=version.published_run.published_run_id, + run_id=version.saved_run.run_id, + uid=version.saved_run.uid, + ) + with st.link(to=url, className="text-decoration-none"): + with st.div( + className="d-flex mb-4 disable-p-margin", + style={"min-width": "min(100vw, 500px)"}, + ): + col1 = st.div(className="me-4") + col2 = st.div() + with col1: + with st.div(className="fs-5 mt-1"): + st.html('') + with col2: + is_first_version = not older_version + with st.div(className="fs-5 d-flex align-items-center"): + js_dynamic_date( + version.created_at, + container=self._render_version_history_date, + date_options={"month": "short", "day": "numeric"}, + ) + if is_first_version: + with st.tag("span", className="badge bg-secondary px-3 ms-2"): + st.write("FIRST VERSION") + with st.div(className="text-muted"): + if older_version and older_version.title != version.title: + st.write(f"Renamed: {version.title}") + elif not older_version: + st.write(version.title) + with st.div(className="mt-1", style={"font-size": "0.85rem"}): + self.render_author( + version.changed_by, image_size="18px", responsive=False + ) + + def _render_version_history_date(self, text, **props): + with st.tag("span", **props): + st.html(text) + def render_related_workflows(self): page_clses = self.related_workflows() if not page_clses: @@ -321,9 +828,10 @@ def render_related_workflows(self): with st.link(to="/explore/"): st.html("