From 32ddf3fb22914292b56d79a750adaf009063d9ac Mon Sep 17 00:00:00 2001 From: jhnnsrs Date: Sun, 23 Jun 2024 13:13:30 +0200 Subject: [PATCH] kammer --- .DS_Store | Bin 8196 -> 10244 bytes kammer/__init__.py | 0 kammer/admin.py | 8 ++ kammer/apps.py | 6 + kammer/channels.py | 3 + kammer/enums.py | 19 +++ kammer/filters.py | 17 +++ kammer/graphql/mutations/__init__.py | 1 + kammer/graphql/mutations/comment.py | 129 ++++++++++++++++++ kammer/graphql/queries/__init__.py | 1 + kammer/graphql/queries/comment.py | 21 +++ kammer/graphql/subscriptions/__init__.py | 1 + kammer/graphql/subscriptions/comment.py | 14 ++ kammer/inputs.py | 28 ++++ kammer/migrations/0001_initial.py | 72 ++++++++++ ..._rename_descendents_comment_descendants.py | 17 +++ ...d_at_alter_comment_descendants_and_more.py | 103 ++++++++++++++ kammer/migrations/__init__.py | 0 kammer/models.py | 86 ++++++++++++ kammer/scalars.py | 19 +++ kammer/signals.py | 0 kammer/tests.py | 3 + kammer/types.py | 104 ++++++++++++++ 23 files changed, 652 insertions(+) create mode 100644 kammer/__init__.py create mode 100644 kammer/admin.py create mode 100644 kammer/apps.py create mode 100644 kammer/channels.py create mode 100644 kammer/enums.py create mode 100644 kammer/filters.py create mode 100644 kammer/graphql/mutations/__init__.py create mode 100644 kammer/graphql/mutations/comment.py create mode 100644 kammer/graphql/queries/__init__.py create mode 100644 kammer/graphql/queries/comment.py create mode 100644 kammer/graphql/subscriptions/__init__.py create mode 100644 kammer/graphql/subscriptions/comment.py create mode 100644 kammer/inputs.py create mode 100644 kammer/migrations/0001_initial.py create mode 100644 kammer/migrations/0002_rename_descendents_comment_descendants.py create mode 100644 kammer/migrations/0003_alter_comment_created_at_alter_comment_descendants_and_more.py create mode 100644 kammer/migrations/__init__.py create mode 100644 kammer/models.py create mode 100644 kammer/scalars.py create mode 100644 kammer/signals.py create mode 100644 kammer/tests.py create mode 100644 kammer/types.py diff --git a/.DS_Store b/.DS_Store index 0f6cbec616c54e5a0b6c6cce0013db3e30f47ccf..5bab20cbbdb22412dfd6e16937c2634ec7b41888 100644 GIT binary patch literal 10244 zcmeHMPiP}m82=_|n@QW%iht09IG~`1Hrv+H6}@D;Sw*myjn;!oO`5dLHp#>!O<7AZ zLie(|B7%4j78KW;iiqF}ikB5n{zL0QgkJO_%AN#4SbyJpljM6dlSU7s)ESt0lX<`I zz2Eoez3;sjBC-}X3o}Fx5!tx8oEXO=7C_GC%O2BZ^o%SJSs=1N*8)60IJmh?du3afYU;p4Z2?dY;j_B% zj(vcValA6^m2F)rQM9Rd50)otd0Pxii=*CQILfqFwson+$7EW$fRp_);&g&16^HN=*xn{&6t6;$|}WvYQ9W{_&yr zzR&MoK0ZjI@HZuD>ynX!<}rh$XbmS0O@r;|`Hy9t)R94y->26a8 z>sH{T=GQ~Ug&rF`_G!wnU$LJhtmk~Kj8aCl!d5I((bx0uKA9o(xWWCJxY$}hoEcs3 z-v(T_4QbiAXMdeu)$@?3??rc5y?=eHU)Z*+W+ua!%U=JfQQ6^joI zk0k7bJ!U_VU3a&#&0KS{v5>1j#=jnR-EzM3p35(lid)MQH=J;*&0MKktSf<1p$N$1 zkCv(hcWc4jtQHzdHmfV_q@7%zxOsbfc7A@+nVsL6bhc;a=H@1ydydWR>?G|QZ$En9 zspXAwrRttzMX|`UbcqaRUL27Dy$o+yFMkz=kv=)Lbm!iiuc{0dXiIwLAe`Z!;c)-7 zvh`}&t#YKZJ_-2G#e7pyGkMeJy?23-(dI)NI?@}dV0%)-#iegLQRiz%>enA-Xcv1C zolzWP-K7na^$?Fv$=XAiY@aUn+P_VKSz3lf9ba0Q%aGapV@hR^3nGIIkT&!l{^cX|JwQU*!ni)05>05%Yo0PZ z#_zs-rRM(u5{D!MB$fV#EsO}ZR_}suLQpcA@BitC@9sFOvOp-ZH^L4^8c{x?=E{)v ze7Rs)dk{~*7da0|FAAigBVf2ht<>%3%g--*Ya;{E(wMG`TxoO|Nkpc^oT4FS>SM5z#7VAGYinIvz_v*$!F~j?k92c#kjUEl?WaT z;qU4=9zXavp0g`|Q`;WzJ?)ikT`F;4lsHO#n*Wdg86eL3qW!=Byx@?m{sMb;V_o#W H-~ay!x7%LO delta 180 zcmZn(XmOBWU|?W$DortDU;r^WfEYvza8E20o2aKKDhZMY@);QN8HyP)8A^aSe`Db^ z_K6L=o7p*7I2a`-KM^XN{8aeb Tuple[Dict, List[str]]: + """Parse Variables + + Recursively traverse variables, applying the apply function to the value if the predicate + returns True. + + Args: + variables (Dict): The dictionary to parse. + + Returns: + Dict: The parsed dictionary. + Mentions: A list of mentions. + + """ + + mentions = [] + + def recurse_extract(obj, path: str = None): + """ + recursively traverse obj, doing a deepcopy, but + replacing any file-like objects with nulls and + shunting the originals off to the side. + """ + + if isinstance(obj, list): + nulled_obj = [] + for key, value in enumerate(obj): + value = recurse_extract( + value, + f"{path}.{key}" if path else key, + ) + nulled_obj.append(value) + return nulled_obj + elif isinstance(obj, dict): + nulled_obj = {} + for key, value in obj.items(): + if key == "kind" and value == "MENTION": + mentions.append(obj) + value = recurse_extract(value, f"{path}.{key}" if path else key) + nulled_obj[key] = value + return nulled_obj + else: + return obj + + dicted_variables = recurse_extract(variables) + + return dicted_variables, mentions + + +@strawberry.input +class CreateCommentInput: + descendants: list[inputs.DescendantInput] + identifier: scalars.Identifier + object: strawberry.ID + parent: strawberry.ID | None = None + notify: bool | None = False + + +def create_comment(info: Info, input: CreateCommentInput) -> types.Comment: + creator = info.context.request.user + + serialized_descendants = strawberry.asdict(input)["descendants"] + + dicted_variables, mentions = recurse_parse_decendents(serialized_descendants) + + # TODO: Check if user is allowed to comment on these types of objects + + exp = models.Comment.objects.create( + identifier=input.identifier, + object=input.object, + user=creator, + text="", + descendants=serialized_descendants, + parent_id=input.parent, + ) + + users = [get_user_model().objects.get(id=m["user"]) for m in mentions] + if input.notify: + for user in users: + user.notify( + f"You have been mentioned in a comment by {creator.username}", + f"Comment on {input.identifier}", + ) + + print(users) + exp.mentions.set(users) + exp.save() + + return exp + + trace = models.User(name=input.user) + return trace + + +@strawberry.input +class ReplyToCommentInput: + descendants: list[inputs.DescendantInput] + parent: strawberry.ID | None + notify: bool | None + + +def reply_to(info: Info, input: ReplyToCommentInput) -> types.Comment: + raise NotImplementedError("TODO: Implement") + + +@strawberry.input +class ResolveCommentInput: + id: strawberry.ID + notify: bool | None + + +def resolve_comment(info: Info, input: ResolveCommentInput) -> types.Comment: + raise NotImplementedError("TODO: Implement") diff --git a/kammer/graphql/queries/__init__.py b/kammer/graphql/queries/__init__.py new file mode 100644 index 0000000..529341a --- /dev/null +++ b/kammer/graphql/queries/__init__.py @@ -0,0 +1 @@ +from .comment import * diff --git a/kammer/graphql/queries/comment.py b/kammer/graphql/queries/comment.py new file mode 100644 index 0000000..657b32a --- /dev/null +++ b/kammer/graphql/queries/comment.py @@ -0,0 +1,21 @@ +import hashlib +import json +import logging + +import strawberry +from ekke.types import Info +from komment import enums, inputs, models, scalars, types + + +def comment(info: Info, id: strawberry.ID) -> types.Comment: + return models.Comment.objects.get(id=id) + + +def comments_for( + info: Info, identifier: scalars.Identifier, object: strawberry.ID +) -> list[types.Comment]: + return models.Comment.objects.filter(identifier=identifier, object=object) + + +def my_mentions(info: Info) -> types.Comment: + return models.Comment.objects.filter(mentions__contains=info.context.request.user) diff --git a/kammer/graphql/subscriptions/__init__.py b/kammer/graphql/subscriptions/__init__.py new file mode 100644 index 0000000..341ff61 --- /dev/null +++ b/kammer/graphql/subscriptions/__init__.py @@ -0,0 +1 @@ +from .comment import * \ No newline at end of file diff --git a/kammer/graphql/subscriptions/comment.py b/kammer/graphql/subscriptions/comment.py new file mode 100644 index 0000000..7c8fd38 --- /dev/null +++ b/kammer/graphql/subscriptions/comment.py @@ -0,0 +1,14 @@ +from ekke.types import Info +import strawberry_django +import strawberry +from komment import types, models, scalars +from typing import AsyncGenerator +from komment.channels import mention_listen + + +async def mentions( + self, info: Info +) -> AsyncGenerator[types.Comment, None]: + """Join and subscribe to message sent to the given rooms.""" + async for message in mention_listen(info, ["user-" + str(info.context.request.user.id)]): + yield await models.Comment.objects.aget(id=message) diff --git a/kammer/inputs.py b/kammer/inputs.py new file mode 100644 index 0000000..1e80f1f --- /dev/null +++ b/kammer/inputs.py @@ -0,0 +1,28 @@ +from komment import enums, scalars +import strawberry +from typing import Optional +from pydantic import BaseModel +from strawberry.experimental import pydantic +from typing import Any +from strawberry import LazyType + + +class DescendandInputModel(BaseModel): + kind: enums.DescendantKind + children: list["DescendandInputModel"] | None + user: str | None + bold: bool | None + italic: bool | None + code: bool | None + text: str | None + + +@pydantic.input(DescendandInputModel) +class DescendantInput: + kind: enums.DescendantKind + children: list[LazyType["DescendantInput", __name__]] | None + user: str | None + bold: bool | None + italic: bool | None + code: bool | None + text: str | None diff --git a/kammer/migrations/0001_initial.py b/kammer/migrations/0001_initial.py new file mode 100644 index 0000000..dd04747 --- /dev/null +++ b/kammer/migrations/0001_initial.py @@ -0,0 +1,72 @@ +# Generated by Django 4.2.4 on 2023-09-05 16:53 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Comment", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("identifier", models.CharField(max_length=1000)), + ("object", models.PositiveIntegerField()), + ("text", models.TextField()), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("descendents", models.JSONField(default=list)), + ("resolved", models.DateTimeField(blank=True, null=True)), + ( + "mentions", + models.ManyToManyField( + blank=True, + related_name="mentioned_in", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "parent", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="children", + to="komment.comment", + ), + ), + ( + "resolved_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="resolved_comments", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="comments", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + ] diff --git a/kammer/migrations/0002_rename_descendents_comment_descendants.py b/kammer/migrations/0002_rename_descendents_comment_descendants.py new file mode 100644 index 0000000..a357079 --- /dev/null +++ b/kammer/migrations/0002_rename_descendents_comment_descendants.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.4 on 2023-09-06 07:49 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("komment", "0001_initial"), + ] + + operations = [ + migrations.RenameField( + model_name="comment", + old_name="descendents", + new_name="descendants", + ), + ] diff --git a/kammer/migrations/0003_alter_comment_created_at_alter_comment_descendants_and_more.py b/kammer/migrations/0003_alter_comment_created_at_alter_comment_descendants_and_more.py new file mode 100644 index 0000000..be61d5f --- /dev/null +++ b/kammer/migrations/0003_alter_comment_created_at_alter_comment_descendants_and_more.py @@ -0,0 +1,103 @@ +# Generated by Django 4.2.5 on 2024-01-05 20:52 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("komment", "0002_rename_descendents_comment_descendants"), + ] + + operations = [ + migrations.AlterField( + model_name="comment", + name="created_at", + field=models.DateTimeField( + auto_now_add=True, help_text="The time this comment got created" + ), + ), + migrations.AlterField( + model_name="comment", + name="descendants", + field=models.JSONField( + default=list, + help_text="The immediate descendends of the comments. Think typed Rich Representation", + ), + ), + migrations.AlterField( + model_name="comment", + name="identifier", + field=models.CharField( + help_text="The identifier of the object. Consult the documentation for the format", + max_length=1000, + ), + ), + migrations.AlterField( + model_name="comment", + name="mentions", + field=models.ManyToManyField( + blank=True, + help_text="The users that got mentioned in this comment", + related_name="mentioned_in", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AlterField( + model_name="comment", + name="object", + field=models.PositiveIntegerField( + help_text="The object id of the object, on its associated service" + ), + ), + migrations.AlterField( + model_name="comment", + name="parent", + field=models.ForeignKey( + blank=True, + help_text="The parent of this comment. Think Thread", + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="children", + to="komment.comment", + ), + ), + migrations.AlterField( + model_name="comment", + name="resolved", + field=models.DateTimeField( + blank=True, help_text="Is this comment marked as resolved?", null=True + ), + ), + migrations.AlterField( + model_name="comment", + name="resolved_by", + field=models.ForeignKey( + blank=True, + help_text="The user that resolved this comment", + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="resolved_comments", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AlterField( + model_name="comment", + name="text", + field=models.TextField( + help_text="A clear text representation of the rich comment" + ), + ), + migrations.AlterField( + model_name="comment", + name="user", + field=models.ForeignKey( + help_text="The user that created this comment", + on_delete=django.db.models.deletion.CASCADE, + related_name="comments", + to=settings.AUTH_USER_MODEL, + ), + ), + ] diff --git a/kammer/migrations/__init__.py b/kammer/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kammer/models.py b/kammer/models.py new file mode 100644 index 0000000..9777439 --- /dev/null +++ b/kammer/models.py @@ -0,0 +1,86 @@ +from django.contrib.auth import get_user_model +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType +from django.db import models + +from fakts.models import App + +# Create your models here. + + +class Structure(models.Model): + identifier = models.CharField( + max_length=1000, + help_text="The identifier of the object. Consult the documentation for the format", + ) + object = models.PositiveIntegerField( + help_text="The object id of the object, on its associated service" + ) + + +class Room(models.Model): + title = models.Model(max_length=1000, help_text="The Title of the Room") + description = models.CharField(max_length=10000, null=True) + creator = models.ForeignKey( + get_user_model(), on_delete=models.CASCADE, null=True, blank=True + ) + pinned_by = models.ManyToManyField( + get_user_model(), + related_name="pinned_workspaces", + blank=True, + help_text="The users that have pinned the workspace", + ) + + +class Agent(models.Model): + room = models.ForeignKey(Room, on_delete=models.CASCADE) + name = models.CharField(max_length=10000, null=True) + app = models.ForeignKey(App, max_length=4000) + user = models.ForeignKey( + get_user_model(), + on_delete=models.CASCADE, + related_name="comments", + help_text="The user that created this comment", + ) + + +class Message(models.Model): + """ + Message represent the message of an agent on a room + """ + + attached_structures = models.ManyToManyField(Structure) + targets = models.ManyToManyField( + Agent, + related_name="received_messages", + help_text="The agents this message targets", + ) + agent = models.ForeignKey( + "Agent", + on_delete=models.CASCADE, + related_name="sent_message", + help_text="The user that created this comment", + ) + is_streaming = models.ForeignKey("Agent") + text = models.TextField(help_text="A clear text representation of the rich comment") + created_at = models.DateTimeField( + auto_now_add=True, help_text="The time this comment got created" + ) + is_reply_to = models.ForeignKey( + "self", + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="replies", + help_text="Is This a reply to a certain comment", + ) + descendants = models.JSONField( + default=list, + help_text="The immediate descendends of the comments. Think typed Rich Representation", + ) + mentions = models.ManyToManyField( + get_user_model(), + blank=True, + related_name="mentioned_in", + help_text="The users that got mentioned in this comment", + ) diff --git a/kammer/scalars.py b/kammer/scalars.py new file mode 100644 index 0000000..560ed4a --- /dev/null +++ b/kammer/scalars.py @@ -0,0 +1,19 @@ +from typing import NewType +import strawberry + +Identifier = strawberry.scalar( + NewType("Identifier", str), + description="The `Identifier` scalasr typsse represents a reference to a store " + "previously created by the user n a datalayer", + serialize=lambda v: v, + parse_value=lambda v: v, +) + + +UnsafeChild = strawberry.scalar( + NewType("UnsafeChild", object), + description="The `Identifier` scalasr typsse represents a reference to a store " + "previously created by the user n a datalayer", + serialize=lambda v: v, + parse_value=lambda v: v, +) diff --git a/kammer/signals.py b/kammer/signals.py new file mode 100644 index 0000000..e69de29 diff --git a/kammer/tests.py b/kammer/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/kammer/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/kammer/types.py b/kammer/types.py new file mode 100644 index 0000000..d3f5eb3 --- /dev/null +++ b/kammer/types.py @@ -0,0 +1,104 @@ +import strawberry_django +from komment import models, scalars, enums, filters +import strawberry +from enum import Enum +from typing import Optional +from typing import Any, Dict +from typing import ForwardRef +from strawberry import LazyType +from typing import Literal, Union +import datetime +from strawberry.experimental import pydantic +from pydantic import BaseModel, Field +from karakter import types +from ekke.types import Info +import json + + +class DescendantModel(BaseModel): + kind: str + children: list["DescendantUnion"] | None + + +@pydantic.interface(DescendantModel, description="A descendant of a comment. Descendend are used to render rich text in the frontend.") +class Descendant: + kind: enums.DescendantKind = strawberry.field(description="The Kind of a Descendant") + children: list[LazyType["Descendant", __name__]] | None = strawberry.field(description="The children of this descendant. Always empty for leafs") + + @strawberry.field(description="Unsafe children are not typed and fall back to json. This is a workaround if queries get too complex.") + def unsafe_children(self, info: Info) -> list[scalars.UnsafeChild] | None: + return json.loads(json.dumps(self.children)) if self.children else None + + +class LeafDescendantModel(DescendantModel): + kind: Literal["LEAF"] + bold: bool | None + italic: bool | None + underline: bool | None + text: str | None + code: str | None + + +@pydantic.type(LeafDescendantModel, description="A leaf of text. This is the most basic descendant and always ends a tree.") +class LeafDescendant(Descendant): + bold: bool | None = strawberry.field(description="Should we render this text bold?") + italic: bool | None = strawberry.field(description="Should we render this text italic?") + underline: bool | None = strawberry.field(description="Should we render this text underlined?") + text: str | None = strawberry.field(description="The text of the leaf") + code: str | None = strawberry.field(description="Should we render this text as code?") + + +class MentionDescendantModel(DescendantModel): + kind: Literal["MENTION"] + user: str | None + + +@pydantic.type(MentionDescendantModel, description="A mention of a user") +class MentionDescendant(Descendant): + user: types.User | None = strawberry.field(description="The user that got mentioned") + + +class ParagraphDescendantModel(DescendantModel): + kind: Literal["PARAGRAPH"] + size: str | None + + +@pydantic.type(ParagraphDescendantModel, description="A Paragraph of text") +class ParagraphDescendant(Descendant): + size: str | None = strawberry.field(description="The size of the paragraph") + + +DescendantUnion = Union[ + LeafDescendantModel, MentionDescendantModel, ParagraphDescendantModel +] + +DescendantModel.update_forward_refs() +LeafDescendantModel.update_forward_refs() +MentionDescendantModel.update_forward_refs() +ParagraphDescendantModel.update_forward_refs() + + +class Serializer(BaseModel): + """ A simple serializer to convert the descendants to a pydantic model. As union types are not supported yet, we need to do this manually.""" + inside: list[DescendantUnion] + + +@strawberry_django.type(models.Comment) +class Comment: + id: strawberry.ID + object: str + identifier: scalars.Identifier + children: list["Comment"] = strawberry.field(description="The children of this comment") + parent: Optional["Comment"] + created_at: datetime.datetime + mentions: list[types.User] + resolved_by: types.User | None + user: types.User + + @strawberry_django.field + def descendants(self, info: Info) -> list[Descendant]: + return Serializer(inside=self.descendants).inside if self.descendants else None + + @strawberry.field + def resolved(self, info: Info) -> bool: + return self.resolved_by is not None