diff --git a/boofilsic/settings.py b/boofilsic/settings.py index 9a0afe5a..3b31bfdd 100644 --- a/boofilsic/settings.py +++ b/boofilsic/settings.py @@ -297,7 +297,6 @@ INSTALLED_APPS = [ ] INSTALLED_APPS += [ - "management.apps.ManagementConfig", "mastodon.apps.MastodonConfig", "common.apps.CommonConfig", "users.apps.UsersConfig", diff --git a/boofilsic/urls.py b/boofilsic/urls.py index b0be01c7..8fc3f434 100644 --- a/boofilsic/urls.py +++ b/boofilsic/urls.py @@ -41,7 +41,6 @@ urlpatterns = [ path("", include("catalog.urls")), path("", include("journal.urls")), path("timeline/", include("social.urls")), - path("announcement/", include("management.urls")), path("hijack/", include("hijack.urls")), path("", include("common.urls")), path("", include("legacy.urls")), diff --git a/common/templates/_footer.html b/common/templates/_footer.html index 0a4404df..b089d799 100644 --- a/common/templates/_footer.html +++ b/common/templates/_footer.html @@ -7,7 +7,7 @@ rel="noopener" href="{{ link.url }}">{{ link.title }} {% endfor %} - 公告栏 + 公告栏 应用开发
-
- 未读公告 - | 全部 -
- {% for ann in request.user.unread_announcements %} -
- - {{ ann.title }} - ({{ ann.created_time|date }}) - -
{{ ann.get_html_content | safe }}
-
- {% endfor %} -
- {% csrf_token %} - -
+
+ 未读公告 + {% for ann in request.user.unread_announcements %}
{{ ann.html }}
{% endfor %} +
+ {% csrf_token %} + +
+
{% endif %} diff --git a/developer/templates/console.html b/developer/templates/console.html index c1845edc..605c58ca 100644 --- a/developer/templates/console.html +++ b/developer/templates/console.html @@ -37,7 +37,7 @@ Developer Console | {% trans "Your applications" %}

- By using our APIs, you agree to our term and data policy. + By using our APIs, you agree to our Terms of Service.

diff --git a/management/__init__.py b/management/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/management/admin.py b/management/admin.py deleted file mode 100644 index e26dfdf5..00000000 --- a/management/admin.py +++ /dev/null @@ -1,5 +0,0 @@ -from django.contrib import admin - -from .models import * - -admin.site.register(Announcement) diff --git a/management/apps.py b/management/apps.py deleted file mode 100644 index f4ad1a62..00000000 --- a/management/apps.py +++ /dev/null @@ -1,5 +0,0 @@ -from django.apps import AppConfig - - -class ManagementConfig(AppConfig): - name = "management" diff --git a/management/migrations/0001_initial.py b/management/migrations/0001_initial.py deleted file mode 100644 index c4d268ce..00000000 --- a/management/migrations/0001_initial.py +++ /dev/null @@ -1,45 +0,0 @@ -# Generated by Django 3.2.16 on 2023-01-12 01:32 - -import markdownx.models -from django.db import migrations, models - - -class Migration(migrations.Migration): - initial = True - - dependencies = [] - - operations = [ - migrations.CreateModel( - name="Announcement", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("title", models.CharField(max_length=200)), - ("content", markdownx.models.MarkdownxField()), - ( - "slug", - models.SlugField( - allow_unicode=True, - blank=True, - max_length=300, - null=True, - unique=True, - ), - ), - ("created_time", models.DateTimeField(auto_now_add=True)), - ("edited_time", models.DateTimeField(auto_now_add=True)), - ], - options={ - "verbose_name": "Announcement", - "verbose_name_plural": "Announcements", - }, - ), - ] diff --git a/management/migrations/__init__.py b/management/migrations/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/management/models.py b/management/models.py deleted file mode 100644 index 52cb6e0c..00000000 --- a/management/models.py +++ /dev/null @@ -1,44 +0,0 @@ -import re - -from django.db import models -from django.urls import reverse -from django.utils.translation import gettext_lazy as _ -from markdown import markdown -from markdownx.models import MarkdownxField - -RE_HTML_TAG = re.compile(r"<[^>]*>") - - -class Announcement(models.Model): - """Model definition for Announcement.""" - - title = models.CharField(max_length=200) - content = MarkdownxField() - slug = models.SlugField( - max_length=300, allow_unicode=True, unique=True, null=True, blank=True - ) - created_time = models.DateTimeField(auto_now_add=True) - edited_time = models.DateTimeField(auto_now_add=True) - - class Meta: - """Meta definition for Announcement.""" - - verbose_name = "Announcement" - verbose_name_plural = "Announcements" - - def get_absolute_url(self): - return reverse("management:retrieve", kwargs={"pk": self.pk}) - - def get_html_content(self): - html = markdown(self.content) - return html - - def get_plain_content(self): - """ - Get plain text format content - """ - return RE_HTML_TAG.sub(" ", self.get_html_content()) - - def __str__(self): - """Unicode representation of Announcement.""" - return self.title diff --git a/management/templates/management/create_update.html b/management/templates/management/create_update.html deleted file mode 100644 index 929e1cdc..00000000 --- a/management/templates/management/create_update.html +++ /dev/null @@ -1,32 +0,0 @@ -{% load static %} - - - - - - Create/Update Announcement - {% include "common_libs.html" %} - - -
-

Create/Update Announcement

-
-
- {% csrf_token %} - {{ form.as_p }} - -
- {{ form.media }} -
-
- - - diff --git a/management/templates/management/delete.html b/management/templates/management/delete.html deleted file mode 100644 index e02dec2a..00000000 --- a/management/templates/management/delete.html +++ /dev/null @@ -1,24 +0,0 @@ -{% load static %} - - - - - - Delete Announcement - {% include "common_libs.html" %} - - - -
-
- {% csrf_token %} -

Are you sure you want to delete "{{ object }}"?

- -
-
- - diff --git a/management/templates/management/detail.html b/management/templates/management/detail.html deleted file mode 100644 index d6085ac8..00000000 --- a/management/templates/management/detail.html +++ /dev/null @@ -1,33 +0,0 @@ -{% load static %} -{% load i18n %} - - - - - - {% include "common_libs.html" %} - {{ site_name }} - {{ object.title }} - - - {% include "_header.html" %} -
- -
- {% include "_footer.html" %} - - diff --git a/management/tests.py b/management/tests.py deleted file mode 100644 index 7ce503c2..00000000 --- a/management/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/management/urls.py b/management/urls.py deleted file mode 100644 index 6e77b67b..00000000 --- a/management/urls.py +++ /dev/null @@ -1,13 +0,0 @@ -from django.urls import path - -from .views import * - -app_name = "management" -urlpatterns = [ - path("", AnnouncementListView.as_view(), name="list"), - path("/", AnnouncementDetailView.as_view(), name="retrieve"), - path("create/", AnnouncementCreateView.as_view(), name="create"), - path("/", AnnouncementDetailView.as_view(), name="retrieve_slug"), - path("/update/", AnnouncementUpdateView.as_view(), name="update"), - path("/delete/", AnnouncementDeleteView.as_view(), name="delete"), -] diff --git a/management/views.py b/management/views.py deleted file mode 100644 index 2c1f0f7a..00000000 --- a/management/views.py +++ /dev/null @@ -1,58 +0,0 @@ -from django.contrib.auth.decorators import login_required, user_passes_test -from django.shortcuts import get_object_or_404 -from django.urls import reverse_lazy -from django.utils import timezone -from django.utils.decorators import method_decorator -from django.views.generic import ( - CreateView, - DeleteView, - DetailView, - ListView, - UpdateView, -) -from django.views.generic.edit import ModelFormMixin - -from .models import Announcement - -# https://docs.djangoproject.com/en/3.1/topics/class-based-views/intro/ -decorators = [login_required, user_passes_test(lambda u: u.is_superuser)] # type:ignore - - -class AnnouncementDetailView(DetailView, ModelFormMixin): - model = Announcement - fields = ["content"] - template_name = "management/detail.html" - - -class AnnouncementListView(ListView): - model = Announcement - # paginate_by = 1 - template_name = "management/list.html" - - def get_queryset(self): - return Announcement.objects.all().order_by("-pk") - - -@method_decorator(decorators, name="dispatch") -class AnnouncementDeleteView(DeleteView): - model = Announcement - success_url = reverse_lazy("management:list") - template_name = "management/delete.html" - - -@method_decorator(decorators, name="dispatch") -class AnnouncementCreateView(CreateView): - model = Announcement - fields = "__all__" - template_name = "management/create_update.html" - - -@method_decorator(decorators, name="dispatch") -class AnnouncementUpdateView(UpdateView): - model = Announcement - fields = "__all__" - template_name = "management/create_update.html" - - def form_valid(self, form): - form.instance.edited_time = timezone.now() - return super().form_valid(form) diff --git a/takahe/migrations/0001_initial.py b/takahe/migrations/0001_initial.py index 1c68d0e5..7d4ae0a6 100644 --- a/takahe/migrations/0001_initial.py +++ b/takahe/migrations/0001_initial.py @@ -913,4 +913,49 @@ class Migration(migrations.Migration): "db_table": "users_relay", }, ), + migrations.CreateModel( + name="Announcement", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "text", + models.TextField(), + ), + ( + "published", + models.BooleanField( + default=False, + ), + ), + ( + "start", + models.DateTimeField( + blank=True, + null=True, + ), + ), + ( + "end", + models.DateTimeField( + blank=True, + null=True, + ), + ), + ("include_unauthenticated", models.BooleanField(default=False)), + ("created", models.DateTimeField(auto_now_add=True)), + ("updated", models.DateTimeField(auto_now=True)), + ("seen", models.ManyToManyField(blank=True, to="takahe.user")), + ], + options={ + "db_table": "users_announcement", + }, + ), ] diff --git a/takahe/models.py b/takahe/models.py index 2f12d6ee..c766e597 100644 --- a/takahe/models.py +++ b/takahe/models.py @@ -1921,3 +1921,42 @@ class Relay(models.Model): class Meta: # managed = False db_table = "users_relay" + + +class Announcement(models.Model): + """ + A server-wide announcement that users all see and can dismiss. + """ + + text = models.TextField() + + published = models.BooleanField( + default=False, + ) + start = models.DateTimeField( + null=True, + blank=True, + ) + end = models.DateTimeField( + null=True, + blank=True, + ) + + include_unauthenticated = models.BooleanField(default=False) + + # Note that this is against User, not Identity - it's one of the few places + # where we want it to be per login. + seen = models.ManyToManyField("User", blank=True) + + created = models.DateTimeField(auto_now_add=True) + updated = models.DateTimeField(auto_now=True) + + class Meta: + # managed = False + db_table = "users_announcement" + + @property + def html(self) -> str: + from journal.models import render_md + + return mark_safe(render_md(self.text)) diff --git a/takahe/utils.py b/takahe/utils.py index 0fca5060..1e1cdc01 100644 --- a/takahe/utils.py +++ b/takahe/utils.py @@ -891,3 +891,54 @@ class Takahe: return False invite = Invite.objects.filter(token=token).first() return invite and invite.valid + + @staticmethod + def get_announcements(): + now = timezone.now() + return Announcement.objects.filter( + models.Q(start__lte=now) | models.Q(start__isnull=True), + models.Q(end__gte=now) | models.Q(end__isnull=True), + published=True, + ).order_by("-start", "-created") + + @staticmethod + def get_announcements_for_user(u: "NeoUser"): + identity = ( + Identity.objects.filter(pk=u.identity.pk, local=True).first() + if u and u.is_authenticated and u.identity + else None + ) + user = identity.users.all().first() if identity else None + if not user: + return Announcement.objects.none() + now = timezone.now() + return ( + Announcement.objects.filter( + models.Q(start__lte=now) | models.Q(start__isnull=True), + models.Q(end__gte=now) | models.Q(end__isnull=True), + published=True, + ) + .order_by("-start", "-created") + .exclude(seen=user) + ) + + @staticmethod + def mark_announcements_seen(u: "NeoUser"): + identity = ( + Identity.objects.filter(pk=u.identity.pk, local=True).first() + if u and u.is_authenticated and u.identity + else None + ) + user = identity.users.all().first() if identity else None + if not user: + return + now = timezone.now() + for a in ( + Announcement.objects.filter( + models.Q(start__lte=now) | models.Q(start__isnull=True), + published=True, + ) + .order_by("-start", "-created") + .exclude(seen=user) + ): + a.seen.add(user) diff --git a/users/models/preference.py b/users/models/preference.py index 7cf5c87d..a4423d51 100644 --- a/users/models/preference.py +++ b/users/models/preference.py @@ -17,7 +17,6 @@ from django.utils.deconstruct import deconstructible from django.utils.translation import gettext_lazy as _ from loguru import logger -from management.models import Announcement from mastodon.api import * from takahe.utils import Takahe diff --git a/users/models/user.py b/users/models/user.py index 569521d7..7f23841e 100644 --- a/users/models/user.py +++ b/users/models/user.py @@ -18,7 +18,6 @@ from django.utils.deconstruct import deconstructible from django.utils.translation import gettext_lazy as _ from loguru import logger -from management.models import Announcement from mastodon.api import * from takahe.utils import Takahe @@ -382,12 +381,11 @@ class User(AbstractUser): self.sync_relationship() return True - @property + @cached_property def unread_announcements(self): - unread_announcements = Announcement.objects.filter( - pk__gt=self.read_announcement_index - ).order_by("-pk") - return unread_announcements + from takahe.utils import Takahe + + return Takahe.get_announcements_for_user(self) @property def activity_manager(self): diff --git a/management/templates/management/list.html b/users/templates/users/announcements.html similarity index 52% rename from management/templates/management/list.html rename to users/templates/users/announcements.html index d8d23828..2231f691 100644 --- a/management/templates/management/list.html +++ b/users/templates/users/announcements.html @@ -1,5 +1,6 @@ {% load static %} {% load i18n %} +{% load humanize %} @@ -40,29 +41,18 @@ {% if request.user.is_superuser %}🦹🏻{% endif %} {% if request.user.is_staff %}🧙🏻{% endif %} - {% if request.user.is_superuser %} - {% trans '发布新公告' %} - {% endif %} - {% for announcement in object_list %} + {% for announcement in announcements %} - {% empty %} -

{% trans '暂无公告' %}

- {% endfor %} - - {% include "_footer.html" %} - + {{ announcement.html | safe }} + + + {% empty %} +

{% trans '暂无公告' %}

+ {% endfor %} + + {% include "_footer.html" %} + diff --git a/users/templates/users/login.html b/users/templates/users/login.html index b2792ddb..8e55912d 100644 --- a/users/templates/users/login.html +++ b/users/templates/users/login.html @@ -169,7 +169,7 @@

- 继续访问或注册视为同意本站数据方针及使用cookie提供必要功能 + 继续访问或注册视为同意站规协议,及使用cookie提供必要功能
diff --git a/users/templates/users/register.html b/users/templates/users/register.html index d6032c58..d3ab8610 100644 --- a/users/templates/users/register.html +++ b/users/templates/users/register.html @@ -19,7 +19,7 @@

{{ site_name }}还在不断完善中。 丰富的内容需要大家共同创造,试图添加垃圾数据(如添加信息混乱或缺失的书籍、以推广为主要目的的评论)将会受到严肃处理。 - 本站为非盈利站点,cookie和其它数据保管使用原则请参阅站内公告。 + 本站为非盈利站点,cookie和其它数据保管使用原则请参阅站内公告。 本站提供API和导出功能,请妥善备份您的数据,使用过程中遇到的问题或者错误欢迎向维护者提出。感谢理解和支持!

{% endif %} diff --git a/users/urls.py b/users/urls.py index 9a7d16e6..b6cbdc6b 100644 --- a/users/urls.py +++ b/users/urls.py @@ -1,5 +1,7 @@ from django.urls import path +from .account import * +from .profile import * from .views import * app_name = "users" @@ -56,4 +58,9 @@ urlpatterns = [ mark_announcements_read, name="mark_announcements_read", ), + path( + "announcements/", + announcements, + name="announcements", + ), ] diff --git a/users/views.py b/users/views.py index 1522a6b5..be495afa 100644 --- a/users/views.py +++ b/users/views.py @@ -1,10 +1,9 @@ import json -from discord import SyncWebhook from django.contrib.auth.decorators import login_required -from django.core.exceptions import BadRequest, PermissionDenied +from django.core.exceptions import BadRequest from django.http import HttpResponseRedirect -from django.shortcuts import get_object_or_404, redirect, render +from django.shortcuts import redirect, render from django.urls import reverse from django.utils.translation import gettext_lazy as _ @@ -14,14 +13,12 @@ from common.utils import ( HTTPResponseHXRedirect, target_identity_required, ) -from management.models import Announcement from mastodon.api import * from takahe.utils import Takahe from .account import * from .data import * -from .models import APIdentity, Preference, User -from .profile import account_info, account_profile +from .models import APIdentity def render_user_not_found(request, user_name=""): @@ -92,9 +89,8 @@ def fetch_refresh(request): @login_required @target_identity_required +@require_http_methods(["POST"]) def follow(request: AuthedHttpRequest, user_name): - if request.method != "POST": - raise BadRequest() request.user.identity.follow(request.target_identity) return render( request, @@ -105,9 +101,8 @@ def follow(request: AuthedHttpRequest, user_name): @login_required @target_identity_required +@require_http_methods(["POST"]) def unfollow(request: AuthedHttpRequest, user_name): - if request.method != "POST": - raise BadRequest() request.user.identity.unfollow(request.target_identity) return render( request, @@ -118,9 +113,8 @@ def unfollow(request: AuthedHttpRequest, user_name): @login_required @target_identity_required +@require_http_methods(["POST"]) def mute(request: AuthedHttpRequest, user_name): - if request.method != "POST": - raise BadRequest() request.user.identity.mute(request.target_identity) return render( request, @@ -131,9 +125,8 @@ def mute(request: AuthedHttpRequest, user_name): @login_required @target_identity_required +@require_http_methods(["POST"]) def unmute(request: AuthedHttpRequest, user_name): - if request.method != "POST": - raise BadRequest() request.user.identity.unmute(request.target_identity) return render( request, @@ -144,9 +137,8 @@ def unmute(request: AuthedHttpRequest, user_name): @login_required @target_identity_required +@require_http_methods(["POST"]) def block(request: AuthedHttpRequest, user_name): - if request.method != "POST": - raise BadRequest() request.user.identity.block(request.target_identity) return render( request, @@ -156,9 +148,8 @@ def block(request: AuthedHttpRequest, user_name): @login_required +@require_http_methods(["POST"]) def unblock(request: AuthedHttpRequest, user_name): - if request.method != "POST": - raise BadRequest() try: target = APIdentity.get_by_handle(user_name) except APIdentity.DoesNotExist: @@ -176,9 +167,8 @@ def unblock(request: AuthedHttpRequest, user_name): @login_required @target_identity_required +@require_http_methods(["POST"]) def accept_follow_request(request: AuthedHttpRequest, user_name): - if request.method != "POST": - raise BadRequest() request.user.identity.accept_follow_request(request.target_identity) return render( request, @@ -189,9 +179,8 @@ def accept_follow_request(request: AuthedHttpRequest, user_name): @login_required @target_identity_required +@require_http_methods(["POST"]) def reject_follow_request(request: AuthedHttpRequest, user_name): - if request.method != "POST": - raise BadRequest() request.user.identity.reject_follow_request(request.target_identity) return render( request, @@ -201,27 +190,30 @@ def reject_follow_request(request: AuthedHttpRequest, user_name): @login_required +@require_http_methods(["POST"]) def set_layout(request: AuthedHttpRequest): - if request.method == "POST": - layout = json.loads(request.POST.get("layout", "{}")) - if request.POST.get("name") == "profile": - request.user.preference.profile_layout = layout - request.user.preference.save(update_fields=["profile_layout"]) - return redirect(request.user.url) - elif request.POST.get("name") == "discover": - request.user.preference.discover_layout = layout - request.user.preference.save(update_fields=["discover_layout"]) - return redirect(reverse("catalog:discover")) + layout = json.loads(request.POST.get("layout", "{}")) + if request.POST.get("name") == "profile": + request.user.preference.profile_layout = layout + request.user.preference.save(update_fields=["profile_layout"]) + return redirect(request.user.url) + elif request.POST.get("name") == "discover": + request.user.preference.discover_layout = layout + request.user.preference.save(update_fields=["discover_layout"]) + return redirect(reverse("catalog:discover")) raise BadRequest() @login_required +@require_http_methods(["POST"]) def mark_announcements_read(request: AuthedHttpRequest): - if request.method == "POST": - try: - request.user.read_announcement_index = Announcement.objects.latest("pk").pk - request.user.save(update_fields=["read_announcement_index"]) - except ObjectDoesNotExist: - # when there is no annoucenment - pass + Takahe.mark_announcements_seen(request.user) return HttpResponseRedirect(request.META.get("HTTP_REFERER", "/")) + + +def announcements(request): + return render( + request, + "users/announcements.html", + {"announcements": Takahe.get_announcements()}, + )