From e3454a142a712be59933be0acade572ee8c0c29e Mon Sep 17 00:00:00 2001 From: Alexander-D-Karpov Date: Tue, 4 Jul 2023 14:00:05 +0300 Subject: [PATCH] added user history --- ...ns_alter_commentrating_options_and_more.py | 33 ++++++ akarpov/blog/models.py | 32 ++++- ...tions_alter_filereport_options_and_more.py | 25 ++++ akarpov/files/models.py | 19 ++- ..._collection_options_alter_image_options.py | 21 ++++ akarpov/gallery/models.py | 15 ++- akarpov/music/apps.py | 2 +- .../migrations/0002_alter_playlist_options.py | 17 +++ akarpov/music/models.py | 14 ++- akarpov/pipeliner/apps.py | 2 +- akarpov/templates/base.html | 1 + akarpov/templates/blog/post.html | 2 +- akarpov/templates/users/history.html | 28 +++++ .../migrations/0008_alter_form_options.py | 17 +++ akarpov/test_platform/models.py | 8 +- akarpov/tools/promocodes/apps.py | 2 +- akarpov/tools/shortener/models.py | 4 +- akarpov/users/migrations/0009_userhistory.py | 67 +++++++++++ ...tory_description_alter_userhistory_name.py | 23 ++++ ...userhistory_options_userhistory_created.py | 26 ++++ akarpov/users/models.py | 44 ++++++- akarpov/users/services/history.py | 112 ++++++++++++++++++ akarpov/users/signals.py | 52 ++++++++ akarpov/users/urls.py | 10 +- akarpov/users/views.py | 35 +++++- akarpov/utils/models.py | 31 +++++ 26 files changed, 605 insertions(+), 37 deletions(-) create mode 100644 akarpov/blog/migrations/0007_alter_comment_options_alter_commentrating_options_and_more.py create mode 100644 akarpov/files/migrations/0024_alter_file_options_alter_filereport_options_and_more.py create mode 100644 akarpov/gallery/migrations/0003_alter_collection_options_alter_image_options.py create mode 100644 akarpov/music/migrations/0002_alter_playlist_options.py create mode 100644 akarpov/templates/users/history.html create mode 100644 akarpov/test_platform/migrations/0008_alter_form_options.py create mode 100644 akarpov/users/migrations/0009_userhistory.py create mode 100644 akarpov/users/migrations/0010_userhistory_description_alter_userhistory_name.py create mode 100644 akarpov/users/migrations/0011_alter_userhistory_options_userhistory_created.py create mode 100644 akarpov/users/services/history.py create mode 100644 akarpov/utils/models.py diff --git a/akarpov/blog/migrations/0007_alter_comment_options_alter_commentrating_options_and_more.py b/akarpov/blog/migrations/0007_alter_comment_options_alter_commentrating_options_and_more.py new file mode 100644 index 0000000..9ee9640 --- /dev/null +++ b/akarpov/blog/migrations/0007_alter_comment_options_alter_commentrating_options_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 4.2.2 on 2023-07-01 10:43 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("blog", "0006_post_public"), + ] + + operations = [ + migrations.AlterModelOptions( + name="comment", + options={"ordering": ["-rating", "-created"], "verbose_name": "Comment"}, + ), + migrations.AlterModelOptions( + name="commentrating", + options={"verbose_name": "Comment rating"}, + ), + migrations.AlterModelOptions( + name="post", + options={"ordering": ["-created"], "verbose_name": "Post"}, + ), + migrations.AlterModelOptions( + name="postrating", + options={"verbose_name": "Post rating"}, + ), + migrations.AlterModelOptions( + name="tag", + options={"verbose_name": "Tag"}, + ), + ] diff --git a/akarpov/blog/models.py b/akarpov/blog/models.py index 682c57c..3d581ad 100644 --- a/akarpov/blog/models.py +++ b/akarpov/blog/models.py @@ -5,12 +5,13 @@ from django.urls import reverse from akarpov.common.models import BaseImageModel -from akarpov.tools.shortener.models import ShortLink +from akarpov.tools.shortener.models import ShortLinkModel from akarpov.users.models import User +from akarpov.users.services.history import UserHistoryModel from akarpov.utils.string import cleanhtml -class Post(BaseImageModel, ShortLink): +class Post(BaseImageModel, ShortLinkModel, UserHistoryModel): title = models.CharField(max_length=100, blank=False) body = RichTextUploadingField(blank=False) @@ -64,21 +65,28 @@ def get_absolute_url(self): return reverse("blog:post", kwargs={"slug": self.slug}) class Meta: + verbose_name = "Post" ordering = ["-created"] class SlugMeta: slug_length = 3 -class Tag(models.Model): +class Tag(UserHistoryModel): name = models.CharField(max_length=20, unique=True) color = ColorField(blank=True, default="#FF0000") def __str__(self): return self.name + def get_absolute_url(self): + return reverse("blog:post_list") + f"?tag={self.name}" -class PostRating(models.Model): + class Meta: + verbose_name = "Tag" + + +class PostRating(UserHistoryModel): user = models.ForeignKey( User, on_delete=models.CASCADE, related_name="post_ratings" ) @@ -86,6 +94,9 @@ class PostRating(models.Model): vote_up = models.BooleanField(blank=False) + def get_absolute_url(self): + return self.post.get_absolute_url() + def __str__(self): return ( f"{self.user}'s vote up on {self.post.title}" @@ -94,10 +105,11 @@ def __str__(self): ) class Meta: + verbose_name = "Post rating" unique_together = ["user", "post"] -class Comment(models.Model): +class Comment(UserHistoryModel): parent = models.ForeignKey("self", blank=True, null=True, on_delete=models.CASCADE) post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name="comments") author = models.ForeignKey(User, on_delete=models.CASCADE, related_name="comments") @@ -107,14 +119,18 @@ class Comment(models.Model): rating = models.IntegerField(default=0) + def get_absolute_url(self): + return self.post.get_absolute_url() + f"#{self.id}" + def __str__(self): return f"{self.author.username}'s comment on {self.post.title}" class Meta: + verbose_name = "Comment" ordering = ["-rating", "-created"] -class CommentRating(models.Model): +class CommentRating(UserHistoryModel): comment = models.ForeignKey( Comment, on_delete=models.CASCADE, related_name="ratings" ) @@ -127,5 +143,9 @@ class CommentRating(models.Model): def __str__(self): return f"{self.user}'s vote up" if self.vote_up else f"{self.user}'s vote down" + def get_absolute_url(self): + return self.comment.get_absolute_url() + class Meta: + verbose_name = "Comment rating" unique_together = ["comment", "user"] diff --git a/akarpov/files/migrations/0024_alter_file_options_alter_filereport_options_and_more.py b/akarpov/files/migrations/0024_alter_file_options_alter_filereport_options_and_more.py new file mode 100644 index 0000000..ebecd72 --- /dev/null +++ b/akarpov/files/migrations/0024_alter_file_options_alter_filereport_options_and_more.py @@ -0,0 +1,25 @@ +# Generated by Django 4.2.2 on 2023-07-01 10:43 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("files", "0023_rename_download_basefileitem_downloads_and_more"), + ] + + operations = [ + migrations.AlterModelOptions( + name="file", + options={"verbose_name": "File"}, + ), + migrations.AlterModelOptions( + name="filereport", + options={"verbose_name": "File report"}, + ), + migrations.AlterModelOptions( + name="folder", + options={"verbose_name": "Folder"}, + ), + ] diff --git a/akarpov/files/models.py b/akarpov/files/models.py index 8f7467e..da1e747 100644 --- a/akarpov/files/models.py +++ b/akarpov/files/models.py @@ -19,7 +19,8 @@ from polymorphic.models import PolymorphicModel from akarpov.files.services.files import trash_file_upload, user_unique_file_upload -from akarpov.tools.shortener.models import ShortLink +from akarpov.tools.shortener.models import ShortLinkModel +from akarpov.users.services.history import UserHistoryModel class BaseFileItem(PolymorphicModel): @@ -59,7 +60,7 @@ def save(self, *args, **kwargs): super().save(*args, **kwargs) -class File(BaseFileItem, TimeStampedModel, ShortLink): +class File(BaseFileItem, TimeStampedModel, ShortLinkModel, UserHistoryModel): """model to store user's files""" private = BooleanField(default=True) @@ -102,8 +103,11 @@ def get_absolute_url(self): def __str__(self): return f"file: {self.name}" + class Meta: + verbose_name = "File" -class Folder(BaseFileItem, ShortLink): + +class Folder(BaseFileItem, ShortLinkModel, UserHistoryModel): name = CharField(max_length=100) slug = SlugField(max_length=20, blank=True) private = BooleanField(default=True) @@ -118,6 +122,9 @@ def get_absolute_url(self): def __str__(self): return f"folder: {self.name}" + class Meta: + verbose_name = "Folder" + class FileInTrash(TimeStampedModel): name = CharField(max_length=200, blank=True) @@ -132,5 +139,11 @@ class FileReport(Model): created = DateTimeField(auto_now_add=True) file = ForeignKey("files.File", related_name="reports", on_delete=CASCADE) + class Meta: + verbose_name = "File report" + + def get_absolute_url(self): + return self.file.get_absolute_url() + def __str__(self): return f"report on {self.file}" diff --git a/akarpov/gallery/migrations/0003_alter_collection_options_alter_image_options.py b/akarpov/gallery/migrations/0003_alter_collection_options_alter_image_options.py new file mode 100644 index 0000000..300ead1 --- /dev/null +++ b/akarpov/gallery/migrations/0003_alter_collection_options_alter_image_options.py @@ -0,0 +1,21 @@ +# Generated by Django 4.2.2 on 2023-07-01 10:43 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("gallery", "0002_image_extra_data_image_image_city_and_more"), + ] + + operations = [ + migrations.AlterModelOptions( + name="collection", + options={"verbose_name": "Collection"}, + ), + migrations.AlterModelOptions( + name="image", + options={"verbose_name": "Image"}, + ), + ] diff --git a/akarpov/gallery/models.py b/akarpov/gallery/models.py index 0af6fe2..41e8292 100644 --- a/akarpov/gallery/models.py +++ b/akarpov/gallery/models.py @@ -4,11 +4,12 @@ from location_field.models.plain import PlainLocationField from akarpov.common.models import BaseImageModel -from akarpov.tools.shortener.models import ShortLink +from akarpov.tools.shortener.models import ShortLinkModel +from akarpov.users.services.history import UserHistoryModel from akarpov.utils.files import user_file_upload_mixin -class Collection(TimeStampedModel, ShortLink): +class Collection(TimeStampedModel, ShortLinkModel, UserHistoryModel): name = models.CharField(max_length=250, blank=True) description = models.TextField() public = models.BooleanField(default=False) @@ -22,8 +23,11 @@ def get_absolute_url(self): def __str__(self): return self.name + class Meta: + verbose_name = "Collection" -class Image(TimeStampedModel, ShortLink, BaseImageModel): + +class Image(TimeStampedModel, ShortLinkModel, BaseImageModel, UserHistoryModel): collection = models.ForeignKey( "Collection", related_name="images", on_delete=models.CASCADE ) @@ -46,8 +50,11 @@ def get_absolute_url(self): def __str__(self): return self.image.name + class Meta: + verbose_name = "Image" -class Tag(ShortLink): + +class Tag(ShortLinkModel): name = models.CharField(max_length=255, null=True, blank=True) def get_absolute_url(self): diff --git a/akarpov/music/apps.py b/akarpov/music/apps.py index 539024e..bdd37e7 100644 --- a/akarpov/music/apps.py +++ b/akarpov/music/apps.py @@ -2,7 +2,7 @@ class MusicConfig(AppConfig): - default_auto_field = "django.db.models.BigAutoField" + verbose_name = "Music" name = "akarpov.music" def ready(self): diff --git a/akarpov/music/migrations/0002_alter_playlist_options.py b/akarpov/music/migrations/0002_alter_playlist_options.py new file mode 100644 index 0000000..30301db --- /dev/null +++ b/akarpov/music/migrations/0002_alter_playlist_options.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.2 on 2023-07-01 10:43 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("music", "0001_initial"), + ] + + operations = [ + migrations.AlterModelOptions( + name="playlist", + options={"verbose_name": "Playlist"}, + ), + ] diff --git a/akarpov/music/models.py b/akarpov/music/models.py index 5ad435b..c77057e 100644 --- a/akarpov/music/models.py +++ b/akarpov/music/models.py @@ -2,10 +2,11 @@ from django.urls import reverse from akarpov.common.models import BaseImageModel -from akarpov.tools.shortener.models import ShortLink +from akarpov.tools.shortener.models import ShortLinkModel +from akarpov.users.services.history import UserHistoryModel -class Author(BaseImageModel, ShortLink): +class Author(BaseImageModel, ShortLinkModel): name = models.CharField(max_length=200) link = models.URLField(blank=True) @@ -16,7 +17,7 @@ def __str__(self): return self.name -class Album(BaseImageModel, ShortLink): +class Album(BaseImageModel, ShortLinkModel): name = models.CharField(max_length=200) link = models.URLField(blank=True) @@ -27,7 +28,7 @@ def __str__(self): return self.name -class Song(BaseImageModel, ShortLink): +class Song(BaseImageModel, ShortLinkModel): link = models.URLField(blank=True) length = models.IntegerField(null=True) played = models.IntegerField(default=0) @@ -50,7 +51,7 @@ class SlugMeta: slug_length = 10 -class Playlist(ShortLink): +class Playlist(ShortLinkModel, UserHistoryModel): name = models.CharField(max_length=200) private = models.BooleanField(default=False) creator = models.ForeignKey( @@ -64,6 +65,9 @@ def get_absolute_url(self): def get_songs(self): return self.songs.all().values("song") + class Meta: + verbose_name = "Playlist" + class PlaylistSong(models.Model): order = models.IntegerField() diff --git a/akarpov/pipeliner/apps.py b/akarpov/pipeliner/apps.py index 40d8076..5c1cf92 100644 --- a/akarpov/pipeliner/apps.py +++ b/akarpov/pipeliner/apps.py @@ -2,5 +2,5 @@ class PipelinerConfig(AppConfig): - default_auto_field = "django.db.models.BigAutoField" + verbose_name = "Pipeliner" name = "akarpov.pipeliner" diff --git a/akarpov/templates/base.html b/akarpov/templates/base.html index 74a2779..1875cf6 100644 --- a/akarpov/templates/base.html +++ b/akarpov/templates/base.html @@ -85,6 +85,7 @@
  • Settings
  • Profile
  • Activate promocode
  • +
  • History
  • diff --git a/akarpov/templates/blog/post.html b/akarpov/templates/blog/post.html index b85c1b9..2655db7 100644 --- a/akarpov/templates/blog/post.html +++ b/akarpov/templates/blog/post.html @@ -83,6 +83,7 @@ {% for comment in post.get_comments %}
    + {% if comment.author.image_cropped %} avatar function addComment(){ - console.log("aaaaa") } {% endblock %} diff --git a/akarpov/templates/users/history.html b/akarpov/templates/users/history.html new file mode 100644 index 0000000..eb6a108 --- /dev/null +++ b/akarpov/templates/users/history.html @@ -0,0 +1,28 @@ +{% extends 'base.html' %} + +{% block content %} + Delete all history +
      + {% for record in userhistory_list %} +
    • + + {% if record.type == 'note' %} +

      {{ record.name }}

      + {% elif record.type == 'create' %} +

      {{ record.name }}

      + {% elif record.type == 'update' %} +

      {{ record.name }}

      + {% elif record.type == 'delete' %} +

      {{ record.name }}

      + {% elif record.type == 'warning' %} +

      {{ record.name }}

      + {% endif %} +
      +
      {{ record.description }}
      +
      {{ record.created | time:"H:i"}} {{ record.created | date:"d.m.Y"}}
      +
      +
      +
    • + {% endfor %} +
    +{% endblock %} diff --git a/akarpov/test_platform/migrations/0008_alter_form_options.py b/akarpov/test_platform/migrations/0008_alter_form_options.py new file mode 100644 index 0000000..b988d9a --- /dev/null +++ b/akarpov/test_platform/migrations/0008_alter_form_options.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.2 on 2023-07-01 10:43 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("test_platform", "0007_alter_form_short_link"), + ] + + operations = [ + migrations.AlterModelOptions( + name="form", + options={"verbose_name": "Form"}, + ), + ] diff --git a/akarpov/test_platform/models.py b/akarpov/test_platform/models.py index 7d07903..f8f5aa8 100644 --- a/akarpov/test_platform/models.py +++ b/akarpov/test_platform/models.py @@ -8,12 +8,13 @@ from polymorphic.models import PolymorphicModel from akarpov.common.models import BaseImageModel -from akarpov.tools.shortener.models import ShortLink +from akarpov.tools.shortener.models import ShortLinkModel from akarpov.users.models import User +from akarpov.users.services.history import UserHistoryModel from akarpov.utils.base import SubclassesMixin -class Form(BaseImageModel, ShortLink): +class Form(BaseImageModel, ShortLinkModel, UserHistoryModel): name = models.CharField(max_length=200, blank=False) description = models.TextField(blank=True) @@ -45,6 +46,9 @@ def get_absolute_url(self): def __str__(self): return f"form: {self.name}" + class Meta: + verbose_name = "Form" + class BaseQuestion(PolymorphicModel, SubclassesMixin): type = "no_type" diff --git a/akarpov/tools/promocodes/apps.py b/akarpov/tools/promocodes/apps.py index e0a4848..e23a0cc 100644 --- a/akarpov/tools/promocodes/apps.py +++ b/akarpov/tools/promocodes/apps.py @@ -2,5 +2,5 @@ class PromocodesConfig(AppConfig): - default_auto_field = "django.db.models.BigAutoField" + verbose_name = "Promocodes" name = "akarpov.tools.promocodes" diff --git a/akarpov/tools/shortener/models.py b/akarpov/tools/shortener/models.py index 995fb60..6cc8713 100644 --- a/akarpov/tools/shortener/models.py +++ b/akarpov/tools/shortener/models.py @@ -9,7 +9,7 @@ class Link(TimeStampedModel): source = models.URLField(blank=False) - slug = models.SlugField() + slug = models.SlugField(db_index=True) creator = models.ForeignKey( "users.User", related_name="links", null=True, on_delete=models.SET_NULL ) @@ -99,7 +99,7 @@ def update_model_link(sender, instance, **kwargs): instance.short_link = None -class ShortLink(SlugModel): +class ShortLinkModel(SlugModel): short_link: Link | None = models.ForeignKey( "shortener.Link", blank=True, null=True, on_delete=models.SET_NULL ) diff --git a/akarpov/users/migrations/0009_userhistory.py b/akarpov/users/migrations/0009_userhistory.py new file mode 100644 index 0000000..188bf0e --- /dev/null +++ b/akarpov/users/migrations/0009_userhistory.py @@ -0,0 +1,67 @@ +# Generated by Django 4.2.2 on 2023-07-01 10:43 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("contenttypes", "0002_remove_content_type_name"), + ("users", "0008_alter_user_left_file_upload"), + ] + + operations = [ + migrations.CreateModel( + name="UserHistory", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "type", + models.CharField( + choices=[ + ("note", "note"), + ("create", "create"), + ("update", "update"), + ("delete", "delete"), + ("warning", "warning"), + ] + ), + ), + ("name", models.CharField(max_length=500)), + ("object_id", models.PositiveIntegerField()), + ( + "content_type", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="contenttypes.contenttype", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="history", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "indexes": [ + models.Index( + fields=["content_type", "object_id"], + name="users_userh_content_7e9fa5_idx", + ) + ], + }, + ), + ] diff --git a/akarpov/users/migrations/0010_userhistory_description_alter_userhistory_name.py b/akarpov/users/migrations/0010_userhistory_description_alter_userhistory_name.py new file mode 100644 index 0000000..dbbc4be --- /dev/null +++ b/akarpov/users/migrations/0010_userhistory_description_alter_userhistory_name.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.2 on 2023-07-04 09:17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("users", "0009_userhistory"), + ] + + operations = [ + migrations.AddField( + model_name="userhistory", + name="description", + field=models.CharField(blank=True, max_length=500), + ), + migrations.AlterField( + model_name="userhistory", + name="name", + field=models.CharField(max_length=200), + ), + ] diff --git a/akarpov/users/migrations/0011_alter_userhistory_options_userhistory_created.py b/akarpov/users/migrations/0011_alter_userhistory_options_userhistory_created.py new file mode 100644 index 0000000..b97405d --- /dev/null +++ b/akarpov/users/migrations/0011_alter_userhistory_options_userhistory_created.py @@ -0,0 +1,26 @@ +# Generated by Django 4.2.2 on 2023-07-04 09:55 + +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ("users", "0010_userhistory_description_alter_userhistory_name"), + ] + + operations = [ + migrations.AlterModelOptions( + name="userhistory", + options={"ordering": ["-created"]}, + ), + migrations.AddField( + model_name="userhistory", + name="created", + field=models.DateTimeField( + auto_now_add=True, default=django.utils.timezone.now + ), + preserve_default=False, + ), + ] diff --git a/akarpov/users/models.py b/akarpov/users/models.py index 035340e..8f40b58 100644 --- a/akarpov/users/models.py +++ b/akarpov/users/models.py @@ -1,14 +1,16 @@ from django.contrib.auth.models import AbstractUser +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType from django.core.validators import MinValueValidator -from django.db.models import BigIntegerField, CharField, TextField +from django.db import models from django.urls import reverse from django.utils.translation import gettext_lazy as _ from akarpov.common.models import BaseImageModel -from akarpov.tools.shortener.models import ShortLink +from akarpov.tools.shortener.models import ShortLinkModel -class User(AbstractUser, BaseImageModel, ShortLink): +class User(AbstractUser, BaseImageModel, ShortLinkModel): """ Default custom user model for akarpov. If adding fields that need to be filled at user signup, @@ -16,13 +18,13 @@ class User(AbstractUser, BaseImageModel, ShortLink): """ #: First and last name do not cover name patterns around the globe - name = CharField(_("Name of User"), blank=True, max_length=255) - about = TextField(_("Description"), blank=True, max_length=100) + name = models.CharField(_("Name of User"), blank=True, max_length=255) + about = models.TextField(_("Description"), blank=True, max_length=100) first_name = None # type: ignore last_name = None # type: ignore # files - left_file_upload = BigIntegerField( + left_file_upload = models.BigIntegerField( "Left file upload(in bites)", default=0, validators=[MinValueValidator(0)] ) @@ -34,3 +36,33 @@ def get_absolute_url(self): """ return reverse("users:detail", kwargs={"username": self.username}) + + +class UserHistory(models.Model): + class RecordType(models.TextChoices): + note = "note", "note" + create = "create", "create" + update = "update", "update" + delete = "delete", "delete" + warning = "warning", "warning" + + type = models.CharField(choices=RecordType.choices) + created = models.DateTimeField(auto_now_add=True) + user = models.ForeignKey("User", related_name="history", on_delete=models.CASCADE) + name = models.CharField(max_length=200) + description = models.CharField(max_length=500, blank=True) + content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) + object_id = models.PositiveIntegerField() + object = GenericForeignKey("content_type", "object_id") + + def get_link(self): + if hasattr(self.object, "get_absolute_url"): + return self.object.get_absolute_url() + return "" + + class Meta: + ordering = ["-created"] + indexes = [models.Index(fields=["content_type", "object_id"])] + + def __str__(self): + return self diff --git a/akarpov/users/services/history.py b/akarpov/users/services/history.py new file mode 100644 index 0000000..ce0019f --- /dev/null +++ b/akarpov/users/services/history.py @@ -0,0 +1,112 @@ +from django.db import models +from django.db.models import Model + +from akarpov.users.models import User, UserHistory +from akarpov.utils.models import get_app_verbose_name, get_object_name, get_object_user + + +def create_history_note(user: User, name: str, description: str, obj: Model): + UserHistory.objects.create( + type=UserHistory.RecordType.note, + user=user, + name=name, + description=description, + object=obj, + ) + + +def create_history_creation_note(user: User, name: str, description: str, obj: Model): + UserHistory.objects.create( + type=UserHistory.RecordType.create, + user=user, + name=name, + description=description, + object=obj, + ) + + +def create_history_update_note(user: User, name: str, description: str, obj: Model): + UserHistory.objects.create( + type=UserHistory.RecordType.update, + user=user, + name=name, + description=description, + object=obj, + ) + + +def create_history_delete_note(user: User, name: str, description: str, obj: Model): + UserHistory.objects.create( + type=UserHistory.RecordType.delete, + user=user, + name=name, + description=description, + object=obj, + ) + + +def create_history_warning_note(user: User, name: str, description: str, obj: Model): + # TODO: notify user here + UserHistory.objects.create( + type=UserHistory.RecordType.warning, + user=user, + name=name, + description=description, + object=obj, + ) + + +def create_history_creation_note_on_create(sender, instance, created, **kwargs): + if created: + user = get_object_user(instance) + if user: + create_history_creation_note( + user, + get_app_verbose_name(sender._meta.app_label), + f"Created {sender._meta.verbose_name.title()} {get_object_name(instance)}", + instance, + ) + + +def create_history_update_note_on_update(sender, instance, **kwargs): + if instance.id: + user = get_object_user(instance) + if not kwargs["update_fields"]: + create_history_update_note( + user, + get_app_verbose_name(sender._meta.app_label), + f"Updated {sender._meta.verbose_name.title()} {get_object_name(instance)}", + instance, + ) + + +def create_history_delete_note_on_delete(sender, instance, **kwargs): + user = get_object_user(instance) + create_history_delete_note( + user, + get_app_verbose_name(sender._meta.app_label), + f"Deleted {sender._meta.verbose_name.title()} {get_object_name(instance)}", + instance, + ) + + +class UserHistoryModel(models.Model): + """ + creates user history records on model change + """ + + @classmethod + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) + models.signals.pre_save.connect( + create_history_update_note_on_update, sender=cls + ) + models.signals.post_save.connect( + create_history_creation_note_on_create, sender=cls + ) + models.signals.post_delete.connect( + create_history_delete_note_on_delete, sender=cls + ) + + class Meta: + abstract = True diff --git a/akarpov/users/signals.py b/akarpov/users/signals.py index b7667cd..2e19442 100644 --- a/akarpov/users/signals.py +++ b/akarpov/users/signals.py @@ -1,7 +1,20 @@ +from allauth.account.signals import ( + email_added, + email_changed, + email_removed, + password_reset, + user_logged_in, +) +from allauth.socialaccount.signals import social_account_added +from django.contrib.auth import user_logged_out from django.db.models.signals import pre_save from django.dispatch import receiver from akarpov.users.models import User +from akarpov.users.services.history import ( + create_history_note, + create_history_warning_note, +) @receiver(pre_save, sender=User) @@ -9,3 +22,42 @@ def user_create(sender, instance: User, **kwargs): if instance.id is None: # give user some space on file share on register instance.left_file_upload += 100 * 1024 * 1024 + + +@receiver(user_logged_in) +def user_logged_in(request, user, **kwargs): + create_history_note(user, "User", "log in", user) + + +@receiver(user_logged_out) +def user_logged_out(request, user, **kwargs): + create_history_note(user, "User", "log out", user) + + +@receiver(password_reset) +def user_password_reset(request, user, **kwargs): + create_history_warning_note(user, "User", "password reset", user) + + +@receiver(email_changed) +def user_email_change(request, user, from_email_address, to_email_address, **kwargs): + create_history_warning_note( + user, "User", f"user email changed to {to_email_address}", user + ) + + +@receiver(email_added) +def user_email_add(request, user, email_address, **kwargs): + create_history_warning_note(user, "User", f"email {email_address} added", user) + + +@receiver(email_removed) +def user_email_remove(request, user, email_address, **kwargs): + create_history_warning_note(user, "User", f"email {email_address} removed", user) + + +@receiver(social_account_added) +def user_account_add(request, sociallogin, **kwargs): + create_history_warning_note( + request.user, "User", f"added {sociallogin.provider} account", request.user + ) diff --git a/akarpov/users/urls.py b/akarpov/users/urls.py index d841db9..f826ef3 100644 --- a/akarpov/users/urls.py +++ b/akarpov/users/urls.py @@ -1,10 +1,18 @@ from django.urls import path -from akarpov.users.views import user_detail_view, user_redirect_view, user_update_view +from akarpov.users.views import ( + user_detail_view, + user_history_delete_view, + user_history_view, + user_redirect_view, + user_update_view, +) app_name = "users" urlpatterns = [ path("redirect/", view=user_redirect_view, name="redirect"), path("update/", view=user_update_view, name="update"), + path("history/", view=user_history_view, name="history"), + path("history/delete", view=user_history_delete_view, name="history_delete"), path("/", view=user_detail_view, name="detail"), ] diff --git a/akarpov/users/views.py b/akarpov/users/views.py index 61ecfff..6a52884 100644 --- a/akarpov/users/views.py +++ b/akarpov/users/views.py @@ -3,13 +3,15 @@ from django.contrib.messages.views import SuccessMessageMixin from django.urls import reverse from django.utils.translation import gettext_lazy as _ -from django.views.generic import DetailView, RedirectView, UpdateView +from django.views.generic import DetailView, ListView, RedirectView, UpdateView + +from akarpov.users.models import UserHistory +from akarpov.users.services.history import create_history_warning_note User = get_user_model() class UserDetailView(DetailView): - model = User slug_field = "username" slug_url_kwarg = "username" @@ -19,7 +21,6 @@ class UserDetailView(DetailView): class UserUpdateView(LoginRequiredMixin, SuccessMessageMixin, UpdateView): - model = User fields = ["username", "name", "image", "about"] success_message = _("Information successfully updated") @@ -38,7 +39,6 @@ def get_object(self): class UserRedirectView(LoginRequiredMixin, RedirectView): - permanent = False def get_redirect_url(self): @@ -46,3 +46,30 @@ def get_redirect_url(self): user_redirect_view = UserRedirectView.as_view() + + +class UserHistoryListView(LoginRequiredMixin, ListView): + model = UserHistory + template_name = "users/history.html" + + def get_queryset(self): + return UserHistory.objects.filter(user=self.request.user) + + +user_history_view = UserHistoryListView.as_view() + + +class UserHistoryDeleteView(LoginRequiredMixin, RedirectView): + def get_redirect_url(self): + q = UserHistory.objects.filter(user=self.request.user).exclude( + type=UserHistory.RecordType.warning + ) + if q: + q.delete() + create_history_warning_note( + self.request.user, "History", "Deleted history", self.request.user + ) + return reverse("users:history") + + +user_history_delete_view = UserHistoryDeleteView.as_view() diff --git a/akarpov/utils/models.py b/akarpov/utils/models.py new file mode 100644 index 0000000..23f9775 --- /dev/null +++ b/akarpov/utils/models.py @@ -0,0 +1,31 @@ +from functools import lru_cache + +from django.apps import apps +from django.db.models import Model + +from akarpov.users.models import User + + +def get_object_name(obj: Model) -> str: + if hasattr(obj, "title"): + return obj.title + elif hasattr(obj, "name"): + return obj.name + elif hasattr(obj, "__str__"): + return obj.__str__() + return "" + + +def get_object_user(obj: Model) -> User | None: + if hasattr(obj, "creator"): + return obj.creator + elif hasattr(obj, "user"): + return obj.user + elif hasattr(obj, "owner"): + return obj.owner + return None + + +@lru_cache +def get_app_verbose_name(app: str) -> str: + return apps.get_app_config(app).verbose_name