use announcement model from takahe

This commit is contained in:
Your Name 2024-04-17 00:00:40 -04:00 committed by Henri Dickson
parent 93b1dd403a
commit 05821eaac1
27 changed files with 203 additions and 355 deletions

View file

@ -297,7 +297,6 @@ INSTALLED_APPS = [
]
INSTALLED_APPS += [
"management.apps.ManagementConfig",
"mastodon.apps.MastodonConfig",
"common.apps.CommonConfig",
"users.apps.UsersConfig",

View file

@ -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")),

View file

@ -7,7 +7,7 @@
rel="noopener"
href="{{ link.url }}">{{ link.title }}</a>
{% endfor %}
<a class="footer__link" href="{% url 'management:list' %}">公告栏</a>
<a class="footer__link" href="{% url 'users:announcements' %}">公告栏</a>
<a class="footer__link" href="{% url 'oauth2_provider:developer' %}">应用开发</a>
<a class="footer__link"
title="{{ neodb_version }}"

View file

@ -12,23 +12,14 @@
{% if request.user.unread_announcements %}
<section class="announcement">
<article>
<h5>
未读公告
<small> | <a href="{% url 'management:list' %}">全部</a> </small>
</h5>
{% for ann in request.user.unread_announcements %}
<details open>
<summary>
{{ ann.title }}
<small>(<a href="{% url 'management:retrieve' ann.pk %}">{{ ann.created_time|date }}</a>)</small>
</summary>
<div class="tldr" _="on click toggle .tldr on me">{{ ann.get_html_content | safe }}</div>
</details>
{% endfor %}
<summary>未读公告</summary>
{% for ann in request.user.unread_announcements %}<div>{{ ann.html }}</div>{% endfor %}
<form action="{% url 'users:mark_announcements_read' %}" method="post">
{% csrf_token %}
<input type="submit" class="secondary outline" value="{% trans '全部标为已读' %}">
</form>
</details>
</article>
</section>
{% endif %}

View file

@ -37,7 +37,7 @@
Developer Console | <a href="{% url 'oauth2_provider:list' %}">{% trans "Your applications" %}</a>
</h3>
<p class="empty">
By using our APIs, you agree to our <a href="{% url 'management:retrieve_slug' 'developer-term' %}">term</a> and <a href="{% url 'management:retrieve_slug' 'data-policy' %}">data policy</a>.
By using our APIs, you agree to our <a href="/pages/terms">Terms of Service</a>.
</p>
<details {% if token %}open{% endif %}>
<summary>

View file

View file

@ -1,5 +0,0 @@
from django.contrib import admin
from .models import *
admin.site.register(Announcement)

View file

@ -1,5 +0,0 @@
from django.apps import AppConfig
class ManagementConfig(AppConfig):
name = "management"

View file

@ -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",
},
),
]

View file

@ -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

View file

@ -1,32 +0,0 @@
{% load static %}
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Create/Update Announcement</title>
{% include "common_libs.html" %}
</head>
<body>
<div class="container">
<h1>Create/Update Announcement</h1>
<article>
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<input type="submit" value="Save">
</form>
{{ form.media }}
</article>
</div>
<script>
function convertToSlug(text) {
// return encodeURI(text.toLowerCase().replace(/ +/g, '-'));
return text.toLowerCase().replace(/[ ;\.\:\?\!@#\$%\^&\*\(\)\=\+\<\>`~·,。“”?!《》【】:;\\{}\[\]]+/g, '-');
}
document.getElementById("id_title").addEventListener('input', function (evt) {
document.getElementById("id_slug").value = convertToSlug(this.value);
});
</script>
</body>
</html>

View file

@ -1,24 +0,0 @@
{% load static %}
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Delete Announcement</title>
{% include "common_libs.html" %}
</head>
<body>
<style>
body {
padding-top: 40px;
}
</style>
<div class="container">
<form method="post">
{% csrf_token %}
<p>Are you sure you want to delete "{{ object }}"?</p>
<input type="submit" value="Confirm">
</form>
</div>
</body>
</html>

View file

@ -1,33 +0,0 @@
{% load static %}
{% load i18n %}
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{% include "common_libs.html" %}
<title>{{ site_name }} - {{ object.title }}</title>
</head>
<body>
{% include "_header.html" %}
<main class="container">
<article>
<header>
<h3>{{ object.title }}</h3>
{{ object.created_time|date }}
{% if request.user.is_superuser %}
<span class="operations">
<a href="{% url 'management:delete' object.pk %}">{% trans '删除' %}</a>
<a href="{% url 'management:update' object.pk %}">{% trans '编辑' %}</a>
</span>
{% endif %}
</header>
<p>{{ object.get_html_content | safe }}</p>
<footer>
<a href="{% url 'management:list' %}">{% trans '返回公告栏' %}</a>
</footer>
</article>
</main>
{% include "_footer.html" %}
</body>
</html>

View file

@ -1,3 +0,0 @@
from django.test import TestCase
# Create your tests here.

View file

@ -1,13 +0,0 @@
from django.urls import path
from .views import *
app_name = "management"
urlpatterns = [
path("", AnnouncementListView.as_view(), name="list"),
path("<int:pk>/", AnnouncementDetailView.as_view(), name="retrieve"),
path("create/", AnnouncementCreateView.as_view(), name="create"),
path("<str:slug>/", AnnouncementDetailView.as_view(), name="retrieve_slug"),
path("<int:pk>/update/", AnnouncementUpdateView.as_view(), name="update"),
path("<int:pk>/delete/", AnnouncementDeleteView.as_view(), name="delete"),
]

View file

@ -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)

View file

@ -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",
},
),
]

View file

@ -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))

View file

@ -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)

View file

@ -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

View file

@ -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):

View file

@ -1,5 +1,6 @@
{% load static %}
{% load i18n %}
{% load humanize %}
<!DOCTYPE html>
<html lang="zh">
<head>
@ -40,29 +41,18 @@
{% if request.user.is_superuser %}🦹🏻{% endif %}
{% if request.user.is_staff %}🧙🏻{% endif %}
</h1>
{% if request.user.is_superuser %}
<a href="{% url 'management:create' %}" class="add">{% trans '发布新公告' %}</a>
{% endif %}
{% for announcement in object_list %}
{% for announcement in announcements %}
<article>
<header>
<a href="{{ announcement.get_absolute_url }}">
<h4>{{ announcement.title }}</h4>
</a>
<span class="datetime">{{ announcement.created_time }}</span>
{% if request.user.is_superuser %}
<span class="operations">
<a href="{% url 'management:update' announcement.pk %}">{% trans '编辑' %}</a>
<a href="{% url 'management:delete' announcement.pk %}">{% trans '删除' %}</a>
{{ announcement.html | safe }}
<footer>
<span class="action inline"><span><a>{{ announcement.created|default:announcement.created|naturaltime }}</a></span>
</span>
{% endif %}
</header>
<p>{{ announcement.get_html_content | safe }}</p>
</footer>
</article>
{% empty %}
<p>{% trans '暂无公告' %}</p>
{% endfor %}
</main>
{% include "_footer.html" %}
</body>
</body>
</html>

View file

@ -169,7 +169,7 @@
</article>
<footer>
<br>
<small>继续访问或注册视为同意本站<a href="{% url 'management:retrieve_slug' 'data-policy' %}">数据方针</a>及使用cookie提供必要功能</small>
<small>继续访问或注册视为同意<a href="/pages/rules/">站规</a><a href="/pages/terms/">协议</a>及使用cookie提供必要功能</small>
</footer>
</body>
</html>

View file

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

View file

@ -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",
),
]

View file

@ -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,8 +190,8 @@ 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
@ -216,12 +205,15 @@ def set_layout(request: AuthedHttpRequest):
@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()},
)