reformat with black
This commit is contained in:
parent
43f0b36bbc
commit
160b8b1c46
39 changed files with 244 additions and 1036 deletions
|
@ -11,6 +11,6 @@ import os
|
||||||
|
|
||||||
from django.core.asgi import get_asgi_application
|
from django.core.asgi import get_asgi_application
|
||||||
|
|
||||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'boofilsic.settings')
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "boofilsic.settings")
|
||||||
|
|
||||||
application = get_asgi_application()
|
application = get_asgi_application()
|
||||||
|
|
|
@ -11,6 +11,6 @@ import os
|
||||||
|
|
||||||
from django.core.wsgi import get_wsgi_application
|
from django.core.wsgi import get_wsgi_application
|
||||||
|
|
||||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'boofilsic.settings')
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "boofilsic.settings")
|
||||||
|
|
||||||
application = get_wsgi_application()
|
application = get_wsgi_application()
|
||||||
|
|
|
@ -2,4 +2,4 @@ from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
class CommonConfig(AppConfig):
|
class CommonConfig(AppConfig):
|
||||||
name = 'common'
|
name = "common"
|
||||||
|
|
114
common/forms.py
114
common/forms.py
|
@ -11,31 +11,35 @@ class KeyValueInput(forms.Widget):
|
||||||
"""
|
"""
|
||||||
Input widget for Json field
|
Input widget for Json field
|
||||||
"""
|
"""
|
||||||
template_name = 'widgets/hstore.html'
|
|
||||||
|
template_name = "widgets/hstore.html"
|
||||||
|
|
||||||
def get_context(self, name, value, attrs):
|
def get_context(self, name, value, attrs):
|
||||||
context = super().get_context(name, value, attrs)
|
context = super().get_context(name, value, attrs)
|
||||||
data = None
|
data = None
|
||||||
if context['widget']['value'] is not None:
|
if context["widget"]["value"] is not None:
|
||||||
data = json.loads(context['widget']['value'])
|
data = json.loads(context["widget"]["value"])
|
||||||
context['widget']['value'] = [{p[0]: p[1]} for p in data.items()] if data else []
|
context["widget"]["value"] = (
|
||||||
|
[{p[0]: p[1]} for p in data.items()] if data else []
|
||||||
|
)
|
||||||
return context
|
return context
|
||||||
|
|
||||||
class Media:
|
class Media:
|
||||||
js = ('js/key_value_input.js',)
|
js = ("js/key_value_input.js",)
|
||||||
|
|
||||||
|
|
||||||
class HstoreInput(forms.Widget):
|
class HstoreInput(forms.Widget):
|
||||||
"""
|
"""
|
||||||
Input widget for Hstore field
|
Input widget for Hstore field
|
||||||
"""
|
"""
|
||||||
template_name = 'widgets/hstore.html'
|
|
||||||
|
template_name = "widgets/hstore.html"
|
||||||
|
|
||||||
def format_value(self, value):
|
def format_value(self, value):
|
||||||
"""
|
"""
|
||||||
Return a value as it should appear when rendered in a template.
|
Return a value as it should appear when rendered in a template.
|
||||||
"""
|
"""
|
||||||
if value == '' or value is None:
|
if value == "" or value is None:
|
||||||
return None
|
return None
|
||||||
if self.is_localized:
|
if self.is_localized:
|
||||||
return formats.localize_input(value)
|
return formats.localize_input(value)
|
||||||
|
@ -43,11 +47,12 @@ class HstoreInput(forms.Widget):
|
||||||
return value
|
return value
|
||||||
|
|
||||||
class Media:
|
class Media:
|
||||||
js = ('js/key_value_input.js',)
|
js = ("js/key_value_input.js",)
|
||||||
|
|
||||||
|
|
||||||
class JSONField(forms.fields.JSONField):
|
class JSONField(forms.fields.JSONField):
|
||||||
widget = KeyValueInput
|
widget = KeyValueInput
|
||||||
|
|
||||||
def to_python(self, value):
|
def to_python(self, value):
|
||||||
if not value:
|
if not value:
|
||||||
return None
|
return None
|
||||||
|
@ -55,7 +60,7 @@ class JSONField(forms.fields.JSONField):
|
||||||
if isinstance(value, dict):
|
if isinstance(value, dict):
|
||||||
j = value
|
j = value
|
||||||
else:
|
else:
|
||||||
pairs = json.loads('[' + value + ']')
|
pairs = json.loads("[" + value + "]")
|
||||||
if isinstance(pairs, dict):
|
if isinstance(pairs, dict):
|
||||||
j = pairs
|
j = pairs
|
||||||
else:
|
else:
|
||||||
|
@ -74,7 +79,7 @@ class RadioBooleanField(forms.ChoiceField):
|
||||||
# will submit for False. Also check for '0', since this is what
|
# will submit for False. Also check for '0', since this is what
|
||||||
# RadioSelect will provide. Because bool("True") == bool('1') == True,
|
# RadioSelect will provide. Because bool("True") == bool('1') == True,
|
||||||
# we don't need to handle that explicitly.
|
# we don't need to handle that explicitly.
|
||||||
if isinstance(value, str) and value.lower() in ('false', '0'):
|
if isinstance(value, str) and value.lower() in ("false", "0"):
|
||||||
value = False
|
value = False
|
||||||
else:
|
else:
|
||||||
value = bool(value)
|
value = bool(value)
|
||||||
|
@ -82,22 +87,24 @@ class RadioBooleanField(forms.ChoiceField):
|
||||||
|
|
||||||
|
|
||||||
class RatingValidator:
|
class RatingValidator:
|
||||||
""" empty value is not validated """
|
"""empty value is not validated"""
|
||||||
|
|
||||||
def __call__(self, value):
|
def __call__(self, value):
|
||||||
if not isinstance(value, int):
|
if not isinstance(value, int):
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
_('%(value)s is not an integer'),
|
_("%(value)s is not an integer"),
|
||||||
params={'value': value},
|
params={"value": value},
|
||||||
)
|
)
|
||||||
if not str(value) in [str(i) for i in range(0, 11)]:
|
if not str(value) in [str(i) for i in range(0, 11)]:
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
_('%(value)s is not an integer in range 1-10'),
|
_("%(value)s is not an integer in range 1-10"),
|
||||||
params={'value': value},
|
params={"value": value},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class PreviewImageInput(forms.FileInput):
|
class PreviewImageInput(forms.FileInput):
|
||||||
template_name = 'widgets/image.html'
|
template_name = "widgets/image.html"
|
||||||
|
|
||||||
def format_value(self, value):
|
def format_value(self, value):
|
||||||
"""
|
"""
|
||||||
Return the file object if it has a defined url attribute.
|
Return the file object if it has a defined url attribute.
|
||||||
|
@ -112,63 +119,70 @@ class PreviewImageInput(forms.FileInput):
|
||||||
"""
|
"""
|
||||||
Return whether value is considered to be initial value.
|
Return whether value is considered to be initial value.
|
||||||
"""
|
"""
|
||||||
return bool(value and getattr(value, 'url', False))
|
return bool(value and getattr(value, "url", False))
|
||||||
|
|
||||||
|
|
||||||
class TagInput(forms.TextInput):
|
class TagInput(forms.TextInput):
|
||||||
"""
|
"""
|
||||||
Dump tag queryset into tag list
|
Dump tag queryset into tag list
|
||||||
"""
|
"""
|
||||||
template_name = 'widgets/tag.html'
|
|
||||||
|
template_name = "widgets/tag.html"
|
||||||
|
|
||||||
def format_value(self, value):
|
def format_value(self, value):
|
||||||
if value == '' or value is None or len(value) == 0:
|
if value == "" or value is None or len(value) == 0:
|
||||||
return ''
|
return ""
|
||||||
tag_list = []
|
tag_list = []
|
||||||
try:
|
try:
|
||||||
tag_list = [t['content'] for t in value]
|
tag_list = [t["content"] for t in value]
|
||||||
except TypeError:
|
except TypeError:
|
||||||
tag_list = [t.content for t in value]
|
tag_list = [t.content for t in value]
|
||||||
# return ','.join(tag_list)
|
# return ','.join(tag_list)
|
||||||
return tag_list
|
return tag_list
|
||||||
|
|
||||||
class Media:
|
class Media:
|
||||||
css = {
|
css = {"all": ("lib/css/tag-input.css",)}
|
||||||
'all': ('lib/css/tag-input.css',)
|
js = ("lib/js/tag-input.js",)
|
||||||
}
|
|
||||||
js = ('lib/js/tag-input.js',)
|
|
||||||
|
|
||||||
|
|
||||||
class TagField(forms.CharField):
|
class TagField(forms.CharField):
|
||||||
"""
|
"""
|
||||||
Split comma connected string into tag list
|
Split comma connected string into tag list
|
||||||
"""
|
"""
|
||||||
|
|
||||||
widget = TagInput
|
widget = TagInput
|
||||||
|
|
||||||
def to_python(self, value):
|
def to_python(self, value):
|
||||||
value = super().to_python(value)
|
value = super().to_python(value)
|
||||||
if not value:
|
if not value:
|
||||||
return
|
return
|
||||||
return [t.strip() for t in value.split(',')]
|
return [t.strip() for t in value.split(",")]
|
||||||
|
|
||||||
|
|
||||||
class MultiSelect(forms.SelectMultiple):
|
class MultiSelect(forms.SelectMultiple):
|
||||||
template_name = 'widgets/multi_select.html'
|
template_name = "widgets/multi_select.html"
|
||||||
|
|
||||||
class Media:
|
class Media:
|
||||||
css = {
|
css = {
|
||||||
'all': ('https://cdn.jsdelivr.net/npm/multiple-select@1.5.2/dist/multiple-select.min.css',)
|
"all": (
|
||||||
|
"https://cdn.jsdelivr.net/npm/multiple-select@1.5.2/dist/multiple-select.min.css",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
js = ('https://cdn.jsdelivr.net/npm/multiple-select@1.5.2/dist/multiple-select.min.js',)
|
js = (
|
||||||
|
"https://cdn.jsdelivr.net/npm/multiple-select@1.5.2/dist/multiple-select.min.js",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class HstoreField(forms.CharField):
|
class HstoreField(forms.CharField):
|
||||||
widget = HstoreInput
|
widget = HstoreInput
|
||||||
|
|
||||||
def to_python(self, value):
|
def to_python(self, value):
|
||||||
if not value:
|
if not value:
|
||||||
return None
|
return None
|
||||||
# already in python types
|
# already in python types
|
||||||
if isinstance(value, list):
|
if isinstance(value, list):
|
||||||
return value
|
return value
|
||||||
pairs = json.loads('[' + value + ']')
|
pairs = json.loads("[" + value + "]")
|
||||||
return pairs
|
return pairs
|
||||||
|
|
||||||
|
|
||||||
|
@ -176,12 +190,13 @@ class DurationInput(forms.TextInput):
|
||||||
"""
|
"""
|
||||||
HH:mm:ss input widget
|
HH:mm:ss input widget
|
||||||
"""
|
"""
|
||||||
|
|
||||||
input_type = "time"
|
input_type = "time"
|
||||||
|
|
||||||
def get_context(self, name, value, attrs):
|
def get_context(self, name, value, attrs):
|
||||||
context = super().get_context(name, value, attrs)
|
context = super().get_context(name, value, attrs)
|
||||||
# context['widget']['type'] = self.input_type
|
# context['widget']['type'] = self.input_type
|
||||||
context['widget']['attrs']['step'] = "1"
|
context["widget"]["attrs"]["step"] = "1"
|
||||||
return context
|
return context
|
||||||
|
|
||||||
def format_value(self, value):
|
def format_value(self, value):
|
||||||
|
@ -206,10 +221,11 @@ class DurationInput(forms.TextInput):
|
||||||
|
|
||||||
class DurationField(forms.TimeField):
|
class DurationField(forms.TimeField):
|
||||||
widget = DurationInput
|
widget = DurationInput
|
||||||
|
|
||||||
def to_python(self, value):
|
def to_python(self, value):
|
||||||
|
|
||||||
# empty value
|
# empty value
|
||||||
if value is None or value == '':
|
if value is None or value == "":
|
||||||
return
|
return
|
||||||
|
|
||||||
# if value is integer in ms
|
# if value is integer in ms
|
||||||
|
@ -217,7 +233,7 @@ class DurationField(forms.TimeField):
|
||||||
return value
|
return value
|
||||||
|
|
||||||
# if value is string in time format
|
# if value is string in time format
|
||||||
h, m, s = value.split(':')
|
h, m, s = value.split(":")
|
||||||
return (int(h) * 3600 + int(m) * 60 + int(s)) * 1000
|
return (int(h) * 3600 + int(m) * 60 + int(s)) * 1000
|
||||||
|
|
||||||
|
|
||||||
|
@ -231,33 +247,34 @@ VISIBILITY_CHOICES = [
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class MarkForm(forms.ModelForm):
|
class MarkForm(forms.ModelForm):
|
||||||
id = forms.IntegerField(required=False, widget=forms.HiddenInput())
|
id = forms.IntegerField(required=False, widget=forms.HiddenInput())
|
||||||
share_to_mastodon = forms.BooleanField(
|
share_to_mastodon = forms.BooleanField(
|
||||||
label=_("分享到联邦网络"), initial=True, required=False)
|
label=_("分享到联邦网络"), initial=True, required=False
|
||||||
|
)
|
||||||
rating = forms.IntegerField(
|
rating = forms.IntegerField(
|
||||||
label=_("评分"), validators=[RatingValidator()], widget=forms.HiddenInput(), required=False)
|
label=_("评分"),
|
||||||
|
validators=[RatingValidator()],
|
||||||
|
widget=forms.HiddenInput(),
|
||||||
|
required=False,
|
||||||
|
)
|
||||||
visibility = forms.TypedChoiceField(
|
visibility = forms.TypedChoiceField(
|
||||||
label=_("可见性"),
|
label=_("可见性"),
|
||||||
initial=0,
|
initial=0,
|
||||||
coerce=int,
|
coerce=int,
|
||||||
choices=VISIBILITY_CHOICES,
|
choices=VISIBILITY_CHOICES,
|
||||||
widget=forms.RadioSelect
|
widget=forms.RadioSelect,
|
||||||
)
|
)
|
||||||
tags = TagField(
|
tags = TagField(
|
||||||
required=False,
|
required=False,
|
||||||
widget=TagInput(attrs={'placeholder': _("回车增加标签")}),
|
widget=TagInput(attrs={"placeholder": _("回车增加标签")}),
|
||||||
label=_("标签")
|
label=_("标签"),
|
||||||
)
|
)
|
||||||
text = forms.CharField(
|
text = forms.CharField(
|
||||||
required=False,
|
required=False,
|
||||||
widget=forms.Textarea(
|
widget=forms.Textarea(
|
||||||
attrs={
|
attrs={"placeholder": _("最多只能写360字哦~"), "maxlength": 360}
|
||||||
"placeholder": _("最多只能写360字哦~"),
|
|
||||||
"maxlength": 360
|
|
||||||
}
|
|
||||||
),
|
),
|
||||||
|
|
||||||
label=_("短评"),
|
label=_("短评"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -266,12 +283,13 @@ class ReviewForm(forms.ModelForm):
|
||||||
title = forms.CharField(label=_("标题"))
|
title = forms.CharField(label=_("标题"))
|
||||||
content = MarkdownxFormField(label=_("正文 (Markdown)"))
|
content = MarkdownxFormField(label=_("正文 (Markdown)"))
|
||||||
share_to_mastodon = forms.BooleanField(
|
share_to_mastodon = forms.BooleanField(
|
||||||
label=_("分享到联邦网络"), initial=True, required=False)
|
label=_("分享到联邦网络"), initial=True, required=False
|
||||||
|
)
|
||||||
id = forms.IntegerField(required=False, widget=forms.HiddenInput())
|
id = forms.IntegerField(required=False, widget=forms.HiddenInput())
|
||||||
visibility = forms.TypedChoiceField(
|
visibility = forms.TypedChoiceField(
|
||||||
label=_("可见性"),
|
label=_("可见性"),
|
||||||
initial=0,
|
initial=0,
|
||||||
coerce=int,
|
coerce=int,
|
||||||
choices=VISIBILITY_CHOICES,
|
choices=VISIBILITY_CHOICES,
|
||||||
widget=forms.RadioSelect
|
widget=forms.RadioSelect,
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,12 +0,0 @@
|
||||||
from django.conf import settings
|
|
||||||
|
|
||||||
|
|
||||||
if settings.SEARCH_BACKEND == 'MEILISEARCH':
|
|
||||||
from .search.meilisearch import Indexer
|
|
||||||
elif settings.SEARCH_BACKEND == 'TYPESENSE':
|
|
||||||
from .search.typesense import Indexer
|
|
||||||
else:
|
|
||||||
class Indexer:
|
|
||||||
@classmethod
|
|
||||||
def update_model_indexable(self, cls):
|
|
||||||
pass
|
|
|
@ -6,14 +6,14 @@ from rq import Queue
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
help = 'Delete a job'
|
help = "Delete a job"
|
||||||
|
|
||||||
def add_arguments(self, parser):
|
def add_arguments(self, parser):
|
||||||
parser.add_argument('job_id', type=str, help='Job ID')
|
parser.add_argument("job_id", type=str, help="Job ID")
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
redis = Redis()
|
redis = Redis()
|
||||||
job_id = str(options['job_id'])
|
job_id = str(options["job_id"])
|
||||||
job = Job.fetch(job_id, connection=redis)
|
job = Job.fetch(job_id, connection=redis)
|
||||||
job.delete()
|
job.delete()
|
||||||
self.stdout.write(self.style.SUCCESS(f'Deleted {job}'))
|
self.stdout.write(self.style.SUCCESS(f"Deleted {job}"))
|
||||||
|
|
|
@ -6,19 +6,25 @@ from rq import Queue
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
help = 'Show jobs in queue'
|
help = "Show jobs in queue"
|
||||||
|
|
||||||
def add_arguments(self, parser):
|
def add_arguments(self, parser):
|
||||||
parser.add_argument('queue', type=str, help='Queue')
|
parser.add_argument("queue", type=str, help="Queue")
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
redis = Redis()
|
redis = Redis()
|
||||||
queue = Queue(str(options['queue']), connection=redis)
|
queue = Queue(str(options["queue"]), connection=redis)
|
||||||
for registry in [queue.started_job_registry, queue.deferred_job_registry, queue.finished_job_registry, queue.failed_job_registry, queue.scheduled_job_registry]:
|
for registry in [
|
||||||
self.stdout.write(self.style.SUCCESS(f'Registry {registry}'))
|
queue.started_job_registry,
|
||||||
|
queue.deferred_job_registry,
|
||||||
|
queue.finished_job_registry,
|
||||||
|
queue.failed_job_registry,
|
||||||
|
queue.scheduled_job_registry,
|
||||||
|
]:
|
||||||
|
self.stdout.write(self.style.SUCCESS(f"Registry {registry}"))
|
||||||
for job_id in registry.get_job_ids():
|
for job_id in registry.get_job_ids():
|
||||||
try:
|
try:
|
||||||
job = Job.fetch(job_id, connection=redis)
|
job = Job.fetch(job_id, connection=redis)
|
||||||
pprint.pp(job)
|
pprint.pp(job)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f'Error fetching {job_id}')
|
print(f"Error fetching {job_id}")
|
||||||
|
|
|
@ -1,28 +0,0 @@
|
||||||
from django.core.management.base import BaseCommand
|
|
||||||
from redis import Redis
|
|
||||||
from rq.job import Job
|
|
||||||
from sync.models import SyncTask
|
|
||||||
from sync.jobs import import_doufen_task
|
|
||||||
from django.utils import timezone
|
|
||||||
import django_rq
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
|
||||||
help = 'Restart a sync task'
|
|
||||||
|
|
||||||
def add_arguments(self, parser):
|
|
||||||
parser.add_argument('synctask_id', type=int, help='Sync Task ID')
|
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
|
||||||
task = SyncTask.objects.get(id=options['synctask_id'])
|
|
||||||
task.finished_items = 0
|
|
||||||
task.failed_urls = []
|
|
||||||
task.success_items = 0
|
|
||||||
task.total_items = 0
|
|
||||||
task.is_finished = False
|
|
||||||
task.is_failed = False
|
|
||||||
task.break_point = ''
|
|
||||||
task.started_time = timezone.now()
|
|
||||||
task.save()
|
|
||||||
django_rq.get_queue('doufen').enqueue(import_doufen_task, task, job_id=f'SyncTask_{task.id}')
|
|
||||||
self.stdout.write(self.style.SUCCESS(f'Queued {task}'))
|
|
|
@ -1,25 +0,0 @@
|
||||||
from django.core.management.base import BaseCommand
|
|
||||||
from common.scraper import get_scraper_by_url, get_normalized_url
|
|
||||||
import pprint
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
|
||||||
help = 'Scrape an item from URL (but not save it)'
|
|
||||||
|
|
||||||
def add_arguments(self, parser):
|
|
||||||
parser.add_argument('url', type=str, help='URL to scrape')
|
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
|
||||||
url = str(options['url'])
|
|
||||||
url = get_normalized_url(url)
|
|
||||||
scraper = get_scraper_by_url(url)
|
|
||||||
|
|
||||||
if scraper is None:
|
|
||||||
self.stdout.write(self.style.ERROR(f'Unable to match a scraper for {url}'))
|
|
||||||
return
|
|
||||||
|
|
||||||
effective_url = scraper.get_effective_url(url)
|
|
||||||
self.stdout.write(f'Fetching {effective_url} via {scraper.__name__}')
|
|
||||||
data, img = scraper.scrape(effective_url)
|
|
||||||
self.stdout.write(self.style.SUCCESS(f'Done.'))
|
|
||||||
pprint.pp(data)
|
|
367
common/models.py
367
common/models.py
|
@ -1,367 +0,0 @@
|
||||||
import re
|
|
||||||
from decimal import *
|
|
||||||
from markdown import markdown
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
|
||||||
from django.db import models, IntegrityError
|
|
||||||
from django.core.serializers.json import DjangoJSONEncoder
|
|
||||||
from django.db.models import Q, Count, Sum
|
|
||||||
from markdownx.models import MarkdownxField
|
|
||||||
from users.models import User
|
|
||||||
from django.utils import timezone
|
|
||||||
from django.conf import settings
|
|
||||||
|
|
||||||
|
|
||||||
RE_HTML_TAG = re.compile(r"<[^>]*>")
|
|
||||||
MAX_TOP_TAGS = 5
|
|
||||||
|
|
||||||
|
|
||||||
# abstract base classes
|
|
||||||
###################################
|
|
||||||
class SourceSiteEnum(models.TextChoices):
|
|
||||||
IN_SITE = "in-site", settings.CLIENT_NAME
|
|
||||||
DOUBAN = "douban", _("豆瓣")
|
|
||||||
SPOTIFY = "spotify", _("Spotify")
|
|
||||||
IMDB = "imdb", _("IMDb")
|
|
||||||
STEAM = "steam", _("STEAM")
|
|
||||||
BANGUMI = 'bangumi', _("bangumi")
|
|
||||||
GOODREADS = "goodreads", _("goodreads")
|
|
||||||
TMDB = "tmdb", _("The Movie Database")
|
|
||||||
GOOGLEBOOKS = "googlebooks", _("Google Books")
|
|
||||||
BANDCAMP = "bandcamp", _("BandCamp")
|
|
||||||
IGDB = "igdb", _("IGDB")
|
|
||||||
|
|
||||||
|
|
||||||
class Entity(models.Model):
|
|
||||||
|
|
||||||
rating_total_score = models.PositiveIntegerField(null=True, blank=True)
|
|
||||||
rating_number = models.PositiveIntegerField(null=True, blank=True)
|
|
||||||
rating = models.DecimalField(
|
|
||||||
null=True, blank=True, max_digits=3, decimal_places=1)
|
|
||||||
created_time = models.DateTimeField(auto_now_add=True)
|
|
||||||
edited_time = models.DateTimeField(auto_now=True)
|
|
||||||
last_editor = models.ForeignKey(
|
|
||||||
User, on_delete=models.SET_NULL, related_name='%(class)s_last_editor', null=True, blank=False)
|
|
||||||
brief = models.TextField(_("简介"), blank=True, default="")
|
|
||||||
other_info = models.JSONField(_("其他信息"),
|
|
||||||
blank=True, null=True, encoder=DjangoJSONEncoder, default=dict)
|
|
||||||
# source_url should include shceme, which is normally https://
|
|
||||||
source_url = models.URLField(_("URL"), max_length=500, unique=True)
|
|
||||||
source_site = models.CharField(_("源网站"), choices=SourceSiteEnum.choices, max_length=50)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
abstract = True
|
|
||||||
constraints = [
|
|
||||||
models.CheckConstraint(check=models.Q(
|
|
||||||
rating__gte=0), name='%(class)s_rating_lowerbound'),
|
|
||||||
models.CheckConstraint(check=models.Q(
|
|
||||||
rating__lte=10), name='%(class)s_rating_upperbound'),
|
|
||||||
]
|
|
||||||
|
|
||||||
def get_absolute_url(self):
|
|
||||||
raise NotImplementedError("Subclass should implement this method")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def url(self):
|
|
||||||
return self.get_absolute_url()
|
|
||||||
|
|
||||||
@property
|
|
||||||
def absolute_url(self):
|
|
||||||
"""URL with host and protocol"""
|
|
||||||
return settings.APP_WEBSITE + self.url
|
|
||||||
|
|
||||||
def get_json(self):
|
|
||||||
return {
|
|
||||||
'title': self.title,
|
|
||||||
'brief': self.brief,
|
|
||||||
'rating': self.rating,
|
|
||||||
'url': self.url,
|
|
||||||
'cover_url': self.cover.url,
|
|
||||||
'top_tags': self.tags[:5],
|
|
||||||
'category_name': self.verbose_category_name,
|
|
||||||
'other_info': self.other_info,
|
|
||||||
}
|
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
|
||||||
""" update rating and strip source url scheme & querystring before save to db """
|
|
||||||
if self.rating_number and self.rating_total_score:
|
|
||||||
self.rating = Decimal(
|
|
||||||
str(round(self.rating_total_score / self.rating_number, 1)))
|
|
||||||
elif self.rating_number is None and self.rating_total_score is None:
|
|
||||||
self.rating = None
|
|
||||||
else:
|
|
||||||
raise IntegrityError()
|
|
||||||
super().save(*args, **kwargs)
|
|
||||||
|
|
||||||
def calculate_rating(self, old_rating, new_rating):
|
|
||||||
if (not (self.rating and self.rating_total_score and self.rating_number)
|
|
||||||
and (self.rating or self.rating_total_score or self.rating_number))\
|
|
||||||
or (not (self.rating or self.rating_number or self.rating_total_score) and old_rating is not None):
|
|
||||||
raise IntegrityError("Rating integiry error.")
|
|
||||||
if old_rating:
|
|
||||||
if new_rating:
|
|
||||||
# old -> new
|
|
||||||
self.rating_total_score += (new_rating - old_rating)
|
|
||||||
else:
|
|
||||||
# old -> none
|
|
||||||
if self.rating_number >= 2:
|
|
||||||
self.rating_total_score -= old_rating
|
|
||||||
self.rating_number -= 1
|
|
||||||
else:
|
|
||||||
# only one rating record
|
|
||||||
self.rating_number = None
|
|
||||||
self.rating_total_score = None
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
if new_rating:
|
|
||||||
# none -> new
|
|
||||||
if self.rating_number and self.rating_number >= 1:
|
|
||||||
self.rating_total_score += new_rating
|
|
||||||
self.rating_number += 1
|
|
||||||
else:
|
|
||||||
# no rating record before
|
|
||||||
self.rating_number = 1
|
|
||||||
self.rating_total_score = new_rating
|
|
||||||
else:
|
|
||||||
# none -> none
|
|
||||||
pass
|
|
||||||
|
|
||||||
def update_rating(self, old_rating, new_rating):
|
|
||||||
"""
|
|
||||||
@param old_rating: the old mark rating
|
|
||||||
@param new_rating: the new mark rating
|
|
||||||
"""
|
|
||||||
self.calculate_rating(old_rating, new_rating)
|
|
||||||
self.save()
|
|
||||||
|
|
||||||
def refresh_rating(self): # TODO: replace update_rating()
|
|
||||||
a = self.marks.filter(rating__gt=0).aggregate(Sum('rating'), Count('rating'))
|
|
||||||
if self.rating_total_score != a['rating__sum'] or self.rating_number != a['rating__count']:
|
|
||||||
self.rating_total_score = a['rating__sum']
|
|
||||||
self.rating_number = a['rating__count']
|
|
||||||
self.rating = a['rating__sum'] / a['rating__count'] if a['rating__count'] > 0 else None
|
|
||||||
self.save()
|
|
||||||
return self.rating
|
|
||||||
|
|
||||||
def get_tags_manager(self):
|
|
||||||
"""
|
|
||||||
Since relation between tag and entity is foreign key, and related name has to be unique,
|
|
||||||
this method works like interface.
|
|
||||||
"""
|
|
||||||
raise NotImplementedError("Subclass should implement this method.")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def top_tags(self):
|
|
||||||
return self.get_tags_manager().values('content').annotate(tag_frequency=Count('content')).order_by('-tag_frequency')[:MAX_TOP_TAGS]
|
|
||||||
|
|
||||||
def get_marks_manager(self):
|
|
||||||
"""
|
|
||||||
Normally this won't be used.
|
|
||||||
There is no ocassion where visitor can simply view all the marks.
|
|
||||||
"""
|
|
||||||
raise NotImplementedError("Subclass should implement this method.")
|
|
||||||
|
|
||||||
def get_reviews_manager(self):
|
|
||||||
"""
|
|
||||||
Normally this won't be used.
|
|
||||||
There is no ocassion where visitor can simply view all the reviews.
|
|
||||||
"""
|
|
||||||
raise NotImplementedError("Subclass should implement this method.")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def all_tag_list(self):
|
|
||||||
return self.get_tags_manager().values('content').annotate(frequency=Count('content')).order_by('-frequency')
|
|
||||||
|
|
||||||
@property
|
|
||||||
def tags(self):
|
|
||||||
return list(map(lambda t: t['content'], self.all_tag_list))
|
|
||||||
|
|
||||||
@property
|
|
||||||
def marks(self):
|
|
||||||
params = {self.__class__.__name__.lower() + '_id': self.id}
|
|
||||||
return self.mark_class.objects.filter(**params)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_category_mapping_dict(cls):
|
|
||||||
category_mapping_dict = {}
|
|
||||||
for subclass in cls.__subclasses__():
|
|
||||||
category_mapping_dict[subclass.__name__.lower()] = subclass
|
|
||||||
return category_mapping_dict
|
|
||||||
|
|
||||||
@property
|
|
||||||
def category_name(self):
|
|
||||||
return self.__class__.__name__
|
|
||||||
|
|
||||||
@property
|
|
||||||
def verbose_category_name(self):
|
|
||||||
raise NotImplementedError("Subclass should implement this.")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def mark_class(self):
|
|
||||||
raise NotImplementedError("Subclass should implement this.")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def tag_class(self):
|
|
||||||
raise NotImplementedError("Subclass should implement this.")
|
|
||||||
|
|
||||||
|
|
||||||
class UserOwnedEntity(models.Model):
|
|
||||||
is_private = models.BooleanField(default=False, null=True) # first set allow null, then migration, finally (in a few days) remove for good
|
|
||||||
visibility = models.PositiveSmallIntegerField(default=0) # 0: Public / 1: Follower only / 2: Self only
|
|
||||||
owner = models.ForeignKey(User, on_delete=models.CASCADE, related_name='user_%(class)ss')
|
|
||||||
created_time = models.DateTimeField(default=timezone.now)
|
|
||||||
edited_time = models.DateTimeField(default=timezone.now)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
abstract = True
|
|
||||||
|
|
||||||
def is_visible_to(self, viewer):
|
|
||||||
if not viewer.is_authenticated:
|
|
||||||
return self.visibility == 0
|
|
||||||
owner = self.owner
|
|
||||||
if owner == viewer:
|
|
||||||
return True
|
|
||||||
if not owner.is_active:
|
|
||||||
return False
|
|
||||||
if self.visibility == 2:
|
|
||||||
return False
|
|
||||||
if viewer.is_blocking(owner) or owner.is_blocking(viewer) or viewer.is_muting(owner):
|
|
||||||
return False
|
|
||||||
if self.visibility == 1:
|
|
||||||
return viewer.is_following(owner)
|
|
||||||
else:
|
|
||||||
return True
|
|
||||||
|
|
||||||
def is_editable_by(self, viewer):
|
|
||||||
return True if viewer.is_staff or viewer.is_superuser or viewer == self.owner else False
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_available(cls, entity, request_user, following_only=False):
|
|
||||||
# e.g. SongMark.get_available(song, request.user)
|
|
||||||
query_kwargs = {entity.__class__.__name__.lower(): entity}
|
|
||||||
all_entities = cls.objects.filter(**query_kwargs).order_by("-created_time") # get all marks for song
|
|
||||||
visible_entities = list(filter(lambda _entity: _entity.is_visible_to(request_user) and (_entity.owner.mastodon_username in request_user.mastodon_following if following_only else True), all_entities))
|
|
||||||
return visible_entities
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_available_for_identicals(cls, entity, request_user, following_only=False):
|
|
||||||
# e.g. SongMark.get_available(song, request.user)
|
|
||||||
query_kwargs = {entity.__class__.__name__.lower() + '__in': entity.get_identicals()}
|
|
||||||
all_entities = cls.objects.filter(**query_kwargs).order_by("-created_time") # get all marks for song
|
|
||||||
visible_entities = list(filter(lambda _entity: _entity.is_visible_to(request_user) and (_entity.owner.mastodon_username in request_user.mastodon_following if following_only else True), all_entities))
|
|
||||||
return visible_entities
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_available_by_user(cls, owner, is_following): # FIXME
|
|
||||||
"""
|
|
||||||
Returns all avaliable owner's entities.
|
|
||||||
Mute/Block relation is not handled in this method.
|
|
||||||
|
|
||||||
:param owner: visited user
|
|
||||||
:param is_following: if the current user is following the owner
|
|
||||||
"""
|
|
||||||
user_owned_entities = cls.objects.filter(owner=owner)
|
|
||||||
if is_following:
|
|
||||||
user_owned_entities = user_owned_entities.exclude(visibility=2)
|
|
||||||
else:
|
|
||||||
user_owned_entities = user_owned_entities.filter(visibility=0)
|
|
||||||
return user_owned_entities
|
|
||||||
|
|
||||||
@property
|
|
||||||
def item(self):
|
|
||||||
attr = re.findall(r'[A-Z](?:[a-z]+|[A-Z]*(?=[A-Z]|$))', self.__class__.__name__)[0].lower()
|
|
||||||
return getattr(self, attr)
|
|
||||||
|
|
||||||
|
|
||||||
# commonly used entity classes
|
|
||||||
###################################
|
|
||||||
class MarkStatusEnum(models.TextChoices):
|
|
||||||
WISH = 'wish', _('Wish')
|
|
||||||
DO = 'do', _('Do')
|
|
||||||
COLLECT = 'collect', _('Collect')
|
|
||||||
|
|
||||||
|
|
||||||
class Mark(UserOwnedEntity):
|
|
||||||
status = models.CharField(choices=MarkStatusEnum.choices, max_length=20)
|
|
||||||
rating = models.PositiveSmallIntegerField(blank=True, null=True)
|
|
||||||
text = models.CharField(max_length=5000, blank=True, default='')
|
|
||||||
shared_link = models.CharField(max_length=5000, blank=True, default='')
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return f"Mark({self.id} {self.owner} {self.status.upper()})"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def translated_status(self):
|
|
||||||
raise NotImplementedError("Subclass should implement this.")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def tags(self):
|
|
||||||
tags = self.item.tag_class.objects.filter(mark_id=self.id)
|
|
||||||
return tags
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
abstract = True
|
|
||||||
constraints = [
|
|
||||||
models.CheckConstraint(check=models.Q(
|
|
||||||
rating__gte=0), name='mark_rating_lowerbound'),
|
|
||||||
models.CheckConstraint(check=models.Q(
|
|
||||||
rating__lte=10), name='mark_rating_upperbound'),
|
|
||||||
]
|
|
||||||
|
|
||||||
# TODO update entity rating when save
|
|
||||||
# TODO update tags
|
|
||||||
|
|
||||||
|
|
||||||
class Review(UserOwnedEntity):
|
|
||||||
title = models.CharField(max_length=120)
|
|
||||||
content = MarkdownxField()
|
|
||||||
shared_link = models.CharField(max_length=5000, blank=True, default='')
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return self.title
|
|
||||||
|
|
||||||
def get_plain_content(self):
|
|
||||||
"""
|
|
||||||
Get plain text format content
|
|
||||||
"""
|
|
||||||
html = markdown(self.content)
|
|
||||||
return RE_HTML_TAG.sub(' ', html)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
abstract = True
|
|
||||||
|
|
||||||
@property
|
|
||||||
def translated_status(self):
|
|
||||||
return '评论了'
|
|
||||||
|
|
||||||
|
|
||||||
class Tag(models.Model):
|
|
||||||
content = models.CharField(max_length=50)
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return self.content
|
|
||||||
|
|
||||||
@property
|
|
||||||
def edited_time(self):
|
|
||||||
return self.mark.edited_time
|
|
||||||
|
|
||||||
@property
|
|
||||||
def created_time(self):
|
|
||||||
return self.mark.created_time
|
|
||||||
|
|
||||||
@property
|
|
||||||
def text(self):
|
|
||||||
return self.mark.text
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def find_by_user(cls, tag, owner, viewer):
|
|
||||||
qs = cls.objects.filter(content=tag, mark__owner=owner)
|
|
||||||
if owner != viewer:
|
|
||||||
qs = qs.filter(mark__visibility__lte=owner.get_max_visibility(viewer))
|
|
||||||
return qs
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def all_by_user(cls, owner):
|
|
||||||
return cls.objects.filter(mark__owner=owner).values('content').annotate(total=Count('content')).order_by('-total')
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
abstract = True
|
|
|
@ -1,183 +0,0 @@
|
||||||
import logging
|
|
||||||
import meilisearch
|
|
||||||
from django.conf import settings
|
|
||||||
from django.db.models.signals import post_save, post_delete
|
|
||||||
import types
|
|
||||||
|
|
||||||
|
|
||||||
INDEX_NAME = 'items'
|
|
||||||
SEARCHABLE_ATTRIBUTES = ['title', 'orig_title', 'other_title', 'subtitle', 'artist', 'author', 'translator', 'developer', 'director', 'actor', 'playwright', 'pub_house', 'company', 'publisher', 'isbn', 'imdb_code']
|
|
||||||
INDEXABLE_DIRECT_TYPES = ['BigAutoField', 'BooleanField', 'CharField', 'PositiveIntegerField', 'PositiveSmallIntegerField', 'TextField', 'ArrayField']
|
|
||||||
INDEXABLE_TIME_TYPES = ['DateTimeField']
|
|
||||||
INDEXABLE_DICT_TYPES = ['JSONField']
|
|
||||||
INDEXABLE_FLOAT_TYPES = ['DecimalField']
|
|
||||||
# NONINDEXABLE_TYPES = ['ForeignKey', 'FileField',]
|
|
||||||
SEARCH_PAGE_SIZE = 20
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def item_post_save_handler(sender, instance, created, **kwargs):
|
|
||||||
if not created and settings.SEARCH_INDEX_NEW_ONLY:
|
|
||||||
return
|
|
||||||
Indexer.replace_item(instance)
|
|
||||||
|
|
||||||
|
|
||||||
def item_post_delete_handler(sender, instance, **kwargs):
|
|
||||||
Indexer.delete_item(instance)
|
|
||||||
|
|
||||||
|
|
||||||
def tag_post_save_handler(sender, instance, **kwargs):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def tag_post_delete_handler(sender, instance, **kwargs):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class Indexer:
|
|
||||||
class_map = {}
|
|
||||||
_instance = None
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def instance(self):
|
|
||||||
if self._instance is None:
|
|
||||||
self._instance = meilisearch.Client(settings.MEILISEARCH_SERVER, settings.MEILISEARCH_KEY).index(INDEX_NAME)
|
|
||||||
return self._instance
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def init(self):
|
|
||||||
meilisearch.Client(settings.MEILISEARCH_SERVER, settings.MEILISEARCH_KEY).create_index(INDEX_NAME, {'primaryKey': '_id'})
|
|
||||||
self.update_settings()
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def update_settings(self):
|
|
||||||
self.instance().update_searchable_attributes(SEARCHABLE_ATTRIBUTES)
|
|
||||||
self.instance().update_filterable_attributes(['_class', 'tags', 'source_site'])
|
|
||||||
self.instance().update_settings({'displayedAttributes': ['_id', '_class', 'id', 'title', 'tags']})
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_stats(self):
|
|
||||||
return self.instance().get_stats()
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def busy(self):
|
|
||||||
return self.instance().get_stats()['isIndexing']
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def update_model_indexable(self, model):
|
|
||||||
if settings.SEARCH_BACKEND is None:
|
|
||||||
return
|
|
||||||
self.class_map[model.__name__] = model
|
|
||||||
model.indexable_fields = ['tags']
|
|
||||||
model.indexable_fields_time = []
|
|
||||||
model.indexable_fields_dict = []
|
|
||||||
model.indexable_fields_float = []
|
|
||||||
for field in model._meta.get_fields():
|
|
||||||
type = field.get_internal_type()
|
|
||||||
if type in INDEXABLE_DIRECT_TYPES:
|
|
||||||
model.indexable_fields.append(field.name)
|
|
||||||
elif type in INDEXABLE_TIME_TYPES:
|
|
||||||
model.indexable_fields_time.append(field.name)
|
|
||||||
elif type in INDEXABLE_DICT_TYPES:
|
|
||||||
model.indexable_fields_dict.append(field.name)
|
|
||||||
elif type in INDEXABLE_FLOAT_TYPES:
|
|
||||||
model.indexable_fields_float.append(field.name)
|
|
||||||
post_save.connect(item_post_save_handler, sender=model)
|
|
||||||
post_delete.connect(item_post_delete_handler, sender=model)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def obj_to_dict(self, obj):
|
|
||||||
pk = f'{obj.__class__.__name__}-{obj.id}'
|
|
||||||
item = {
|
|
||||||
'_id': pk,
|
|
||||||
'_class': obj.__class__.__name__,
|
|
||||||
# 'id': obj.id
|
|
||||||
}
|
|
||||||
for field in obj.__class__.indexable_fields:
|
|
||||||
item[field] = getattr(obj, field)
|
|
||||||
for field in obj.__class__.indexable_fields_time:
|
|
||||||
item[field] = getattr(obj, field).timestamp()
|
|
||||||
for field in obj.__class__.indexable_fields_float:
|
|
||||||
item[field] = float(getattr(obj, field)) if getattr(obj, field) else None
|
|
||||||
for field in obj.__class__.indexable_fields_dict:
|
|
||||||
d = getattr(obj, field)
|
|
||||||
if d.__class__ is dict:
|
|
||||||
item.update(d)
|
|
||||||
item = {k: v for k, v in item.items() if v}
|
|
||||||
return item
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def replace_item(self, obj):
|
|
||||||
try:
|
|
||||||
self.instance().add_documents([self.obj_to_dict(obj)])
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"replace item error: \n{e}")
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def replace_batch(self, objects):
|
|
||||||
try:
|
|
||||||
self.instance().update_documents(documents=objects)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"replace batch error: \n{e}")
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def delete_item(self, obj):
|
|
||||||
pk = f'{obj.__class__.__name__}-{obj.id}'
|
|
||||||
try:
|
|
||||||
self.instance().delete_document(pk)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"delete item error: \n{e}")
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def patch_item(self, obj, fields):
|
|
||||||
pk = f'{obj.__class__.__name__}-{obj.id}'
|
|
||||||
data = {}
|
|
||||||
for f in fields:
|
|
||||||
data[f] = getattr(obj, f)
|
|
||||||
try:
|
|
||||||
self.instance().update_documents(documents=[data], primary_key=[pk])
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"patch item error: \n{e}")
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def search(self, q, page=1, category=None, tag=None, sort=None):
|
|
||||||
if category or tag:
|
|
||||||
f = []
|
|
||||||
if category == 'music':
|
|
||||||
f.append("(_class = 'Album' OR _class = 'Song')")
|
|
||||||
elif category:
|
|
||||||
f.append(f"_class = '{category}'")
|
|
||||||
if tag:
|
|
||||||
t = tag.replace("'", "\'")
|
|
||||||
f.append(f"tags = '{t}'")
|
|
||||||
filter = ' AND '.join(f)
|
|
||||||
else:
|
|
||||||
filter = None
|
|
||||||
options = {
|
|
||||||
'offset': (page - 1) * SEARCH_PAGE_SIZE,
|
|
||||||
'limit': SEARCH_PAGE_SIZE,
|
|
||||||
'filter': filter,
|
|
||||||
'facetsDistribution': ['_class'],
|
|
||||||
'sort': None
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
r = self.instance().search(q, options)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"MeiliSearch error: \n{e}")
|
|
||||||
r = {'nbHits': 0, 'hits': []}
|
|
||||||
# print(r)
|
|
||||||
results = types.SimpleNamespace()
|
|
||||||
results.items = list([x for x in map(lambda i: self.item_to_obj(i), r['hits']) if x is not None])
|
|
||||||
results.num_pages = (r['nbHits'] + SEARCH_PAGE_SIZE - 1) // SEARCH_PAGE_SIZE
|
|
||||||
# print(results)
|
|
||||||
return results
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def item_to_obj(self, item):
|
|
||||||
try:
|
|
||||||
return self.class_map[item['_class']].objects.get(id=item['id'])
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"unable to load search result item from db:\n{item}")
|
|
||||||
return None
|
|
|
@ -1,217 +0,0 @@
|
||||||
import types
|
|
||||||
import logging
|
|
||||||
import typesense
|
|
||||||
from typesense.exceptions import ObjectNotFound
|
|
||||||
from django.conf import settings
|
|
||||||
from django.db.models.signals import post_save, post_delete
|
|
||||||
|
|
||||||
|
|
||||||
INDEX_NAME = 'items'
|
|
||||||
SEARCHABLE_ATTRIBUTES = ['title', 'orig_title', 'other_title', 'subtitle', 'artist', 'author', 'translator',
|
|
||||||
'developer', 'director', 'actor', 'playwright', 'pub_house', 'company', 'publisher', 'isbn', 'imdb_code']
|
|
||||||
FILTERABLE_ATTRIBUTES = ['_class', 'tags', 'source_site']
|
|
||||||
INDEXABLE_DIRECT_TYPES = ['BigAutoField', 'BooleanField', 'CharField',
|
|
||||||
'PositiveIntegerField', 'PositiveSmallIntegerField', 'TextField', 'ArrayField']
|
|
||||||
INDEXABLE_TIME_TYPES = ['DateTimeField']
|
|
||||||
INDEXABLE_DICT_TYPES = ['JSONField']
|
|
||||||
INDEXABLE_FLOAT_TYPES = ['DecimalField']
|
|
||||||
SORTING_ATTRIBUTE = None
|
|
||||||
# NONINDEXABLE_TYPES = ['ForeignKey', 'FileField',]
|
|
||||||
SEARCH_PAGE_SIZE = 20
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def item_post_save_handler(sender, instance, created, **kwargs):
|
|
||||||
if not created and settings.SEARCH_INDEX_NEW_ONLY:
|
|
||||||
return
|
|
||||||
Indexer.replace_item(instance)
|
|
||||||
|
|
||||||
|
|
||||||
def item_post_delete_handler(sender, instance, **kwargs):
|
|
||||||
Indexer.delete_item(instance)
|
|
||||||
|
|
||||||
|
|
||||||
def tag_post_save_handler(sender, instance, **kwargs):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def tag_post_delete_handler(sender, instance, **kwargs):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class Indexer:
|
|
||||||
class_map = {}
|
|
||||||
_instance = None
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def instance(self):
|
|
||||||
if self._instance is None:
|
|
||||||
self._instance = typesense.Client(settings.TYPESENSE_CONNECTION)
|
|
||||||
return self._instance
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def init(self):
|
|
||||||
# self.instance().collections[INDEX_NAME].delete()
|
|
||||||
# fields = [
|
|
||||||
# {"name": "_class", "type": "string", "facet": True},
|
|
||||||
# {"name": "source_site", "type": "string", "facet": True},
|
|
||||||
# {"name": ".*", "type": "auto", "locale": "zh"},
|
|
||||||
# ]
|
|
||||||
# use dumb schema below before typesense fix a bug
|
|
||||||
fields = [
|
|
||||||
{'name': 'id', 'type': 'string'},
|
|
||||||
{'name': '_id', 'type': 'int64'},
|
|
||||||
{'name': '_class', 'type': 'string', "facet": True},
|
|
||||||
{'name': 'source_site', 'type': 'string', "facet": True},
|
|
||||||
{'name': 'isbn', 'optional': True, 'type': 'string'},
|
|
||||||
{'name': 'imdb_code', 'optional': True, 'type': 'string'},
|
|
||||||
{'name': 'author', 'optional': True, 'locale': 'zh', 'type': 'string[]'},
|
|
||||||
{'name': 'orig_title', 'optional': True, 'locale': 'zh', 'type': 'string'},
|
|
||||||
{'name': 'pub_house', 'optional': True, 'locale': 'zh', 'type': 'string'},
|
|
||||||
{'name': 'title', 'optional': True, 'locale': 'zh', 'type': 'string'},
|
|
||||||
{'name': 'translator', 'optional': True, 'locale': 'zh', 'type': 'string[]'},
|
|
||||||
{'name': 'subtitle', 'optional': True, 'locale': 'zh', 'type': 'string'},
|
|
||||||
{'name': 'artist', 'optional': True, 'locale': 'zh', 'type': 'string[]'},
|
|
||||||
{'name': 'company', 'optional': True, 'locale': 'zh', 'type': 'string[]'},
|
|
||||||
{'name': 'developer', 'optional': True, 'locale': 'zh', 'type': 'string[]'},
|
|
||||||
{'name': 'other_title', 'optional': True, 'locale': 'zh', 'type': 'string[]'},
|
|
||||||
{'name': 'publisher', 'optional': True, 'locale': 'zh', 'type': 'string[]'},
|
|
||||||
{'name': 'actor', 'optional': True, 'locale': 'zh', 'type': 'string[]'},
|
|
||||||
{'name': 'director', 'optional': True, 'locale': 'zh', 'type': 'string[]'},
|
|
||||||
{'name': 'playwright', 'optional': True, 'locale': 'zh', 'type': 'string[]'},
|
|
||||||
{'name': 'tags', 'optional': True, 'locale': 'zh', 'type': 'string[]'},
|
|
||||||
{'name': '.*', 'optional': True, 'locale': 'zh', 'type': 'auto'},
|
|
||||||
]
|
|
||||||
|
|
||||||
self.instance().collections.create({
|
|
||||||
"name": INDEX_NAME,
|
|
||||||
"fields": fields
|
|
||||||
})
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def update_settings(self):
|
|
||||||
# https://github.com/typesense/typesense/issues/96
|
|
||||||
print('not supported by typesense yet')
|
|
||||||
pass
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_stats(self):
|
|
||||||
return self.instance().collections[INDEX_NAME].retrieve()
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def busy(self):
|
|
||||||
return False
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def update_model_indexable(self, model):
|
|
||||||
if settings.SEARCH_BACKEND is None:
|
|
||||||
return
|
|
||||||
self.class_map[model.__name__] = model
|
|
||||||
model.indexable_fields = ['tags']
|
|
||||||
model.indexable_fields_time = []
|
|
||||||
model.indexable_fields_dict = []
|
|
||||||
model.indexable_fields_float = []
|
|
||||||
for field in model._meta.get_fields():
|
|
||||||
type = field.get_internal_type()
|
|
||||||
if type in INDEXABLE_DIRECT_TYPES:
|
|
||||||
model.indexable_fields.append(field.name)
|
|
||||||
elif type in INDEXABLE_TIME_TYPES:
|
|
||||||
model.indexable_fields_time.append(field.name)
|
|
||||||
elif type in INDEXABLE_DICT_TYPES:
|
|
||||||
model.indexable_fields_dict.append(field.name)
|
|
||||||
elif type in INDEXABLE_FLOAT_TYPES:
|
|
||||||
model.indexable_fields_float.append(field.name)
|
|
||||||
post_save.connect(item_post_save_handler, sender=model)
|
|
||||||
post_delete.connect(item_post_delete_handler, sender=model)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def obj_to_dict(self, obj):
|
|
||||||
pk = f'{obj.__class__.__name__}-{obj.id}'
|
|
||||||
item = {
|
|
||||||
'_class': obj.__class__.__name__,
|
|
||||||
}
|
|
||||||
for field in obj.__class__.indexable_fields:
|
|
||||||
item[field] = getattr(obj, field)
|
|
||||||
for field in obj.__class__.indexable_fields_time:
|
|
||||||
item[field] = getattr(obj, field).timestamp()
|
|
||||||
for field in obj.__class__.indexable_fields_float:
|
|
||||||
item[field] = float(getattr(obj, field)) if getattr(
|
|
||||||
obj, field) else None
|
|
||||||
for field in obj.__class__.indexable_fields_dict:
|
|
||||||
d = getattr(obj, field)
|
|
||||||
if d.__class__ is dict:
|
|
||||||
item.update(d)
|
|
||||||
item = {k: v for k, v in item.items() if v and (
|
|
||||||
k in SEARCHABLE_ATTRIBUTES or k in FILTERABLE_ATTRIBUTES or k == 'id')}
|
|
||||||
item['_id'] = obj.id
|
|
||||||
# typesense requires primary key to be named 'id', type string
|
|
||||||
item['id'] = pk
|
|
||||||
return item
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def replace_item(self, obj):
|
|
||||||
try:
|
|
||||||
self.instance().collections[INDEX_NAME].documents.upsert(self.obj_to_dict(obj), {
|
|
||||||
'dirty_values': 'coerce_or_drop'
|
|
||||||
})
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"replace item error: \n{e}")
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def replace_batch(self, objects):
|
|
||||||
try:
|
|
||||||
self.instance().collections[INDEX_NAME].documents.import_(
|
|
||||||
objects, {'action': 'upsert'})
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"replace batch error: \n{e}")
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def delete_item(self, obj):
|
|
||||||
pk = f'{obj.__class__.__name__}-{obj.id}'
|
|
||||||
try:
|
|
||||||
self.instance().collections[INDEX_NAME].documents[pk].delete()
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"delete item error: \n{e}")
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def search(self, q, page=1, category=None, tag=None, sort=None):
|
|
||||||
f = []
|
|
||||||
if category == 'music':
|
|
||||||
f.append('_class:= [Album, Song]')
|
|
||||||
elif category:
|
|
||||||
f.append('_class:= ' + category)
|
|
||||||
else:
|
|
||||||
f.append('')
|
|
||||||
if tag:
|
|
||||||
f.append(f"tags:= '{tag}'")
|
|
||||||
filter = ' && '.join(f)
|
|
||||||
options = {
|
|
||||||
'q': q,
|
|
||||||
'page': page,
|
|
||||||
'per_page': SEARCH_PAGE_SIZE,
|
|
||||||
'query_by': ','.join(SEARCHABLE_ATTRIBUTES),
|
|
||||||
'filter_by': filter,
|
|
||||||
# 'facetsDistribution': ['_class'],
|
|
||||||
# 'sort_by': None,
|
|
||||||
}
|
|
||||||
results = types.SimpleNamespace()
|
|
||||||
|
|
||||||
try:
|
|
||||||
r = self.instance().collections[INDEX_NAME].documents.search(options)
|
|
||||||
results.items = list([x for x in map(lambda i: self.item_to_obj(i['document']), r['hits']) if x is not None])
|
|
||||||
results.num_pages = (r['found'] + SEARCH_PAGE_SIZE - 1) // SEARCH_PAGE_SIZE
|
|
||||||
except ObjectNotFound:
|
|
||||||
results.items = []
|
|
||||||
results.num_pages = 1
|
|
||||||
|
|
||||||
return results
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def item_to_obj(self, item):
|
|
||||||
try:
|
|
||||||
return self.class_map[item['_class']].objects.get(id=item['_id'])
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"unable to load search result item from db:\n{item}")
|
|
||||||
return None
|
|
|
@ -9,8 +9,8 @@ register = template.Library()
|
||||||
@register.simple_tag
|
@register.simple_tag
|
||||||
def admin_url():
|
def admin_url():
|
||||||
url = settings.ADMIN_URL
|
url = settings.ADMIN_URL
|
||||||
if not url.startswith('/'):
|
if not url.startswith("/"):
|
||||||
url = '/' + url
|
url = "/" + url
|
||||||
if not url.endswith('/'):
|
if not url.endswith("/"):
|
||||||
url += '/'
|
url += "/"
|
||||||
return format_html(url)
|
return format_html(url)
|
||||||
|
|
|
@ -4,14 +4,14 @@ from django.template.defaultfilters import stringfilter
|
||||||
from opencc import OpenCC
|
from opencc import OpenCC
|
||||||
|
|
||||||
|
|
||||||
cc = OpenCC('t2s')
|
cc = OpenCC("t2s")
|
||||||
register = template.Library()
|
register = template.Library()
|
||||||
|
|
||||||
|
|
||||||
@register.filter
|
@register.filter
|
||||||
@stringfilter
|
@stringfilter
|
||||||
def highlight(text, search):
|
def highlight(text, search):
|
||||||
for s in cc.convert(search.strip().lower()).split(' '):
|
for s in cc.convert(search.strip().lower()).split(" "):
|
||||||
if s:
|
if s:
|
||||||
p = cc.convert(text.lower()).find(s)
|
p = cc.convert(text.lower()).find(s)
|
||||||
if p != -1:
|
if p != -1:
|
||||||
|
|
|
@ -8,19 +8,19 @@ register = template.Library()
|
||||||
|
|
||||||
@register.simple_tag
|
@register.simple_tag
|
||||||
def mastodon(domain):
|
def mastodon(domain):
|
||||||
url = 'https://' + domain
|
url = "https://" + domain
|
||||||
return url
|
return url
|
||||||
|
|
||||||
|
|
||||||
@register.simple_tag(takes_context=True)
|
@register.simple_tag(takes_context=True)
|
||||||
def current_user_relationship(context, user):
|
def current_user_relationship(context, user):
|
||||||
current_user = context['request'].user
|
current_user = context["request"].user
|
||||||
if current_user and current_user.is_authenticated:
|
if current_user and current_user.is_authenticated:
|
||||||
if current_user.is_following(user):
|
if current_user.is_following(user):
|
||||||
if current_user.is_followed_by(user):
|
if current_user.is_followed_by(user):
|
||||||
return '互相关注'
|
return "互相关注"
|
||||||
else:
|
else:
|
||||||
return '已关注'
|
return "已关注"
|
||||||
elif current_user.is_followed_by(user):
|
elif current_user.is_followed_by(user):
|
||||||
return '被ta关注'
|
return "被ta关注"
|
||||||
return None
|
return None
|
||||||
|
|
|
@ -4,13 +4,14 @@ from django.utils.html import format_html
|
||||||
|
|
||||||
register = template.Library()
|
register = template.Library()
|
||||||
|
|
||||||
|
|
||||||
class OAuthTokenNode(template.Node):
|
class OAuthTokenNode(template.Node):
|
||||||
def render(self, context):
|
def render(self, context):
|
||||||
request = context.get('request')
|
request = context.get("request")
|
||||||
oauth_token = request.user.mastodon_token
|
oauth_token = request.user.mastodon_token
|
||||||
return format_html(oauth_token)
|
return format_html(oauth_token)
|
||||||
|
|
||||||
|
|
||||||
@register.tag
|
@register.tag
|
||||||
def oauth_token(parser, token):
|
def oauth_token(parser, token):
|
||||||
return OAuthTokenNode()
|
return OAuthTokenNode()
|
||||||
|
|
|
@ -11,12 +11,12 @@ def prettydate(d):
|
||||||
diff = timezone.now() - d
|
diff = timezone.now() - d
|
||||||
s = diff.seconds
|
s = diff.seconds
|
||||||
if diff.days > 14 or diff.days < 0:
|
if diff.days > 14 or diff.days < 0:
|
||||||
return d.strftime('%Y年%m月%d日')
|
return d.strftime("%Y年%m月%d日")
|
||||||
elif diff.days >= 1:
|
elif diff.days >= 1:
|
||||||
return '{} 天前'.format(diff.days)
|
return "{} 天前".format(diff.days)
|
||||||
elif s < 120:
|
elif s < 120:
|
||||||
return '刚刚'
|
return "刚刚"
|
||||||
elif s < 3600:
|
elif s < 3600:
|
||||||
return '{} 分钟前'.format(s // 60)
|
return "{} 分钟前".format(s // 60)
|
||||||
else:
|
else:
|
||||||
return '{} 小时前'.format(s // 3600)
|
return "{} 小时前".format(s // 3600)
|
||||||
|
|
|
@ -7,12 +7,12 @@ register = template.Library()
|
||||||
@register.filter(is_safe=True)
|
@register.filter(is_safe=True)
|
||||||
@stringfilter
|
@stringfilter
|
||||||
def strip_scheme(value):
|
def strip_scheme(value):
|
||||||
""" Strip the `https://.../` part of urls"""
|
"""Strip the `https://.../` part of urls"""
|
||||||
if value.startswith("https://"):
|
if value.startswith("https://"):
|
||||||
value = value.lstrip("https://")
|
value = value.lstrip("https://")
|
||||||
elif value.startswith("http://"):
|
elif value.startswith("http://"):
|
||||||
value = value.lstrip("http://")
|
value = value.lstrip("http://")
|
||||||
|
|
||||||
if value.endswith('/'):
|
if value.endswith("/"):
|
||||||
value = value[0:-1]
|
value = value[0:-1]
|
||||||
return value
|
return value
|
||||||
|
|
|
@ -14,4 +14,4 @@ def truncate(value, arg):
|
||||||
length = int(arg)
|
length = int(arg)
|
||||||
except ValueError: # Invalid literal for int().
|
except ValueError: # Invalid literal for int().
|
||||||
return value # Fail silently.
|
return value # Fail silently.
|
||||||
return Truncator(value).chars(length, truncate="...")
|
return Truncator(value).chars(length, truncate="...")
|
||||||
|
|
|
@ -1,12 +1,14 @@
|
||||||
import uuid
|
import uuid
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
|
|
||||||
class PageLinksGenerator:
|
class PageLinksGenerator:
|
||||||
# TODO inherit django paginator
|
# TODO inherit django paginator
|
||||||
"""
|
"""
|
||||||
Calculate the pages for multiple links pagination.
|
Calculate the pages for multiple links pagination.
|
||||||
length -- the number of page links in pagination
|
length -- the number of page links in pagination
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, length, current_page, total_pages):
|
def __init__(self, length, current_page, total_pages):
|
||||||
current_page = int(current_page)
|
current_page = int(current_page)
|
||||||
self.current_page = current_page
|
self.current_page = current_page
|
||||||
|
@ -23,13 +25,12 @@ class PageLinksGenerator:
|
||||||
|
|
||||||
# decision is based on the start page and the end page
|
# decision is based on the start page and the end page
|
||||||
# both sides overflow
|
# both sides overflow
|
||||||
if (start_page < 1 and end_page > total_pages)\
|
if (start_page < 1 and end_page > total_pages) or length >= total_pages:
|
||||||
or length >= total_pages:
|
|
||||||
self.start_page = 1
|
self.start_page = 1
|
||||||
self.end_page = total_pages
|
self.end_page = total_pages
|
||||||
self.has_prev = False
|
self.has_prev = False
|
||||||
self.has_next = False
|
self.has_next = False
|
||||||
|
|
||||||
elif start_page < 1 and not end_page > total_pages:
|
elif start_page < 1 and not end_page > total_pages:
|
||||||
self.start_page = 1
|
self.start_page = 1
|
||||||
# this won't overflow because the total pages are more than the length
|
# this won't overflow because the total pages are more than the length
|
||||||
|
@ -39,7 +40,7 @@ class PageLinksGenerator:
|
||||||
self.has_next = False
|
self.has_next = False
|
||||||
else:
|
else:
|
||||||
self.has_next = True
|
self.has_next = True
|
||||||
|
|
||||||
elif not start_page < 1 and end_page > total_pages:
|
elif not start_page < 1 and end_page > total_pages:
|
||||||
self.end_page = total_pages
|
self.end_page = total_pages
|
||||||
self.start_page = start_page - (end_page - total_pages)
|
self.start_page = start_page - (end_page - total_pages)
|
||||||
|
@ -62,16 +63,12 @@ class PageLinksGenerator:
|
||||||
# assert self.has_prev is not None and self.has_next is not None
|
# assert self.has_prev is not None and self.has_next is not None
|
||||||
|
|
||||||
|
|
||||||
def ChoicesDictGenerator(choices_enum):
|
|
||||||
choices_dict = dict(choices_enum.choices)
|
|
||||||
return choices_dict
|
|
||||||
|
|
||||||
def GenerateDateUUIDMediaFilePath(instance, filename, path_root):
|
def GenerateDateUUIDMediaFilePath(instance, filename, path_root):
|
||||||
ext = filename.split('.')[-1]
|
ext = filename.split(".")[-1]
|
||||||
filename = "%s.%s" % (uuid.uuid4(), ext)
|
filename = "%s.%s" % (uuid.uuid4(), ext)
|
||||||
root = ''
|
root = ""
|
||||||
if path_root.endswith('/'):
|
if path_root.endswith("/"):
|
||||||
root = path_root
|
root = path_root
|
||||||
else:
|
else:
|
||||||
root = path_root + '/'
|
root = path_root + "/"
|
||||||
return root + timezone.now().strftime('%Y/%m/%d') + f'{filename}'
|
return root + timezone.now().strftime("%Y/%m/%d") + f"{filename}"
|
||||||
|
|
|
@ -1,10 +1,6 @@
|
||||||
import logging
|
from django.shortcuts import redirect
|
||||||
from django.shortcuts import render, redirect
|
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.utils.translation import gettext_lazy as _
|
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
|
|
|
@ -2,4 +2,4 @@ from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
class ManagementConfig(AppConfig):
|
class ManagementConfig(AppConfig):
|
||||||
name = 'management'
|
name = "management"
|
||||||
|
|
|
@ -14,25 +14,27 @@ class Announcement(models.Model):
|
||||||
|
|
||||||
title = models.CharField(max_length=200)
|
title = models.CharField(max_length=200)
|
||||||
content = MarkdownxField()
|
content = MarkdownxField()
|
||||||
slug = models.SlugField(max_length=300, allow_unicode=True, unique=True, null=True, blank=True)
|
slug = models.SlugField(
|
||||||
|
max_length=300, allow_unicode=True, unique=True, null=True, blank=True
|
||||||
|
)
|
||||||
created_time = models.DateTimeField(auto_now_add=True)
|
created_time = models.DateTimeField(auto_now_add=True)
|
||||||
edited_time = models.DateTimeField(auto_now_add=True)
|
edited_time = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
"""Meta definition for Announcement."""
|
"""Meta definition for Announcement."""
|
||||||
|
|
||||||
verbose_name = 'Announcement'
|
verbose_name = "Announcement"
|
||||||
verbose_name_plural = 'Announcements'
|
verbose_name_plural = "Announcements"
|
||||||
|
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return reverse('management:retrieve', kwargs={'pk': self.pk})
|
return reverse("management:retrieve", kwargs={"pk": self.pk})
|
||||||
|
|
||||||
def get_plain_content(self):
|
def get_plain_content(self):
|
||||||
"""
|
"""
|
||||||
Get plain text format content
|
Get plain text format content
|
||||||
"""
|
"""
|
||||||
html = markdown(self.content)
|
html = markdown(self.content)
|
||||||
return RE_HTML_TAG.sub(' ', html)
|
return RE_HTML_TAG.sub(" ", html)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
"""Unicode representation of Announcement."""
|
"""Unicode representation of Announcement."""
|
||||||
|
|
|
@ -2,12 +2,12 @@ from django.urls import path
|
||||||
from .views import *
|
from .views import *
|
||||||
|
|
||||||
|
|
||||||
app_name = 'management'
|
app_name = "management"
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('', AnnouncementListView.as_view(), name='list'),
|
path("", AnnouncementListView.as_view(), name="list"),
|
||||||
path('<int:pk>/', AnnouncementDetailView.as_view(), name='retrieve'),
|
path("<int:pk>/", AnnouncementDetailView.as_view(), name="retrieve"),
|
||||||
path('create/', AnnouncementCreateView.as_view(), name='create'),
|
path("create/", AnnouncementCreateView.as_view(), name="create"),
|
||||||
path('<str:slug>/', AnnouncementDetailView.as_view(), name='retrieve_slug'),
|
path("<str:slug>/", AnnouncementDetailView.as_view(), name="retrieve_slug"),
|
||||||
path('<int:pk>/update/', AnnouncementUpdateView.as_view(), name='update'),
|
path("<int:pk>/update/", AnnouncementUpdateView.as_view(), name="update"),
|
||||||
path('<int:pk>/delete/', AnnouncementDeleteView.as_view(), name='delete'),
|
path("<int:pk>/delete/", AnnouncementDeleteView.as_view(), name="delete"),
|
||||||
]
|
]
|
||||||
|
|
|
@ -16,41 +16,39 @@ decorators = [login_required, user_passes_test(lambda u: u.is_superuser)]
|
||||||
|
|
||||||
class AnnouncementDetailView(DetailView, ModelFormMixin):
|
class AnnouncementDetailView(DetailView, ModelFormMixin):
|
||||||
model = Announcement
|
model = Announcement
|
||||||
fields = ['content']
|
fields = ["content"]
|
||||||
template_name = "management/detail.html"
|
template_name = "management/detail.html"
|
||||||
|
|
||||||
|
|
||||||
class AnnouncementListView(ListView):
|
class AnnouncementListView(ListView):
|
||||||
model = Announcement
|
model = Announcement
|
||||||
# paginate_by = 1
|
# paginate_by = 1
|
||||||
template_name = "management/list.html"
|
template_name = "management/list.html"
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return Announcement.objects.all().order_by('-pk')
|
return Announcement.objects.all().order_by("-pk")
|
||||||
|
|
||||||
|
|
||||||
@method_decorator(decorators, name='dispatch')
|
@method_decorator(decorators, name="dispatch")
|
||||||
class AnnouncementDeleteView(DeleteView):
|
class AnnouncementDeleteView(DeleteView):
|
||||||
model = Announcement
|
model = Announcement
|
||||||
success_url = reverse_lazy("management:list")
|
success_url = reverse_lazy("management:list")
|
||||||
template_name = "management/delete.html"
|
template_name = "management/delete.html"
|
||||||
|
|
||||||
|
|
||||||
@method_decorator(decorators, name='dispatch')
|
@method_decorator(decorators, name="dispatch")
|
||||||
class AnnouncementCreateView(CreateView):
|
class AnnouncementCreateView(CreateView):
|
||||||
model = Announcement
|
model = Announcement
|
||||||
fields = '__all__'
|
fields = "__all__"
|
||||||
template_name = "management/create_update.html"
|
template_name = "management/create_update.html"
|
||||||
|
|
||||||
|
|
||||||
@method_decorator(decorators, name='dispatch')
|
@method_decorator(decorators, name="dispatch")
|
||||||
class AnnouncementUpdateView(UpdateView):
|
class AnnouncementUpdateView(UpdateView):
|
||||||
model = Announcement
|
model = Announcement
|
||||||
fields = '__all__'
|
fields = "__all__"
|
||||||
template_name = "management/create_update.html"
|
template_name = "management/create_update.html"
|
||||||
|
|
||||||
def form_valid(self, form):
|
def form_valid(self, form):
|
||||||
form.instance.edited_time = timezone.now()
|
form.instance.edited_time = timezone.now()
|
||||||
return super().form_valid(form)
|
return super().form_valid(form)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1 +1 @@
|
||||||
from .decorators import *
|
from .decorators import *
|
||||||
|
|
|
@ -8,50 +8,63 @@ from django.core.exceptions import ObjectDoesNotExist
|
||||||
# Register your models here.
|
# Register your models here.
|
||||||
@admin.register(MastodonApplication)
|
@admin.register(MastodonApplication)
|
||||||
class MastodonApplicationModelAdmin(admin.ModelAdmin):
|
class MastodonApplicationModelAdmin(admin.ModelAdmin):
|
||||||
|
def add_view(self, request, form_url="", extra_context=None):
|
||||||
def add_view(self, request, form_url='', extra_context=None):
|
|
||||||
"""
|
"""
|
||||||
Dirty code here, use POST['domain_name'] to pass error message to user.
|
Dirty code here, use POST['domain_name'] to pass error message to user.
|
||||||
"""
|
"""
|
||||||
if request.method == 'POST':
|
if request.method == "POST":
|
||||||
if not request.POST.get('client_id') and not request.POST.get('client_secret'):
|
if not request.POST.get("client_id") and not request.POST.get(
|
||||||
|
"client_secret"
|
||||||
|
):
|
||||||
# make the post data mutable
|
# make the post data mutable
|
||||||
request.POST = request.POST.copy()
|
request.POST = request.POST.copy()
|
||||||
# (is_proxy xor proxy_to) or (proxy_to!=null and is_proxy=false)
|
# (is_proxy xor proxy_to) or (proxy_to!=null and is_proxy=false)
|
||||||
if (bool(request.POST.get('is_proxy')) or bool(request.POST.get('proxy_to'))) and\
|
if (
|
||||||
not (bool(request.POST.get('is_proxy')) and bool(request.POST.get('proxy_to'))) or\
|
(
|
||||||
(not bool(request.POST.get('is_proxy')) and bool(request.POST.get('proxy_to'))):
|
bool(request.POST.get("is_proxy"))
|
||||||
request.POST['domain_name'] = _("请同时填写is_proxy和proxy_to。")
|
or bool(request.POST.get("proxy_to"))
|
||||||
|
)
|
||||||
|
and not (
|
||||||
|
bool(request.POST.get("is_proxy"))
|
||||||
|
and bool(request.POST.get("proxy_to"))
|
||||||
|
)
|
||||||
|
or (
|
||||||
|
not bool(request.POST.get("is_proxy"))
|
||||||
|
and bool(request.POST.get("proxy_to"))
|
||||||
|
)
|
||||||
|
):
|
||||||
|
request.POST["domain_name"] = _("请同时填写is_proxy和proxy_to。")
|
||||||
else:
|
else:
|
||||||
if request.POST.get("is_proxy"):
|
if request.POST.get("is_proxy"):
|
||||||
try:
|
try:
|
||||||
origin = MastodonApplication.objects.get(domain_name=request.POST['proxy_to'])
|
origin = MastodonApplication.objects.get(
|
||||||
|
domain_name=request.POST["proxy_to"]
|
||||||
|
)
|
||||||
# set proxy credentials to those of its original site
|
# set proxy credentials to those of its original site
|
||||||
request.POST['app_id'] = origin.app_id
|
request.POST["app_id"] = origin.app_id
|
||||||
request.POST['client_id'] = origin.client_id
|
request.POST["client_id"] = origin.client_id
|
||||||
request.POST['client_secret'] = origin.client_secret
|
request.POST["client_secret"] = origin.client_secret
|
||||||
request.POST['vapid_key'] = origin.vapid_key
|
request.POST["vapid_key"] = origin.vapid_key
|
||||||
except ObjectDoesNotExist:
|
except ObjectDoesNotExist:
|
||||||
request.POST['domain_name'] = _("proxy_to所指域名不存在,请先添加原站点。")
|
request.POST["domain_name"] = _("proxy_to所指域名不存在,请先添加原站点。")
|
||||||
else:
|
else:
|
||||||
# create mastodon app
|
# create mastodon app
|
||||||
try:
|
try:
|
||||||
response = create_app(request.POST.get('domain_name'))
|
response = create_app(request.POST.get("domain_name"))
|
||||||
except (Timeout, ConnectionError):
|
except (Timeout, ConnectionError):
|
||||||
request.POST['domain_name'] = _("联邦网络请求超时。")
|
request.POST["domain_name"] = _("联邦网络请求超时。")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
request.POST['domain_name'] = str(e)
|
request.POST["domain_name"] = str(e)
|
||||||
else:
|
else:
|
||||||
# fill the form with returned data
|
# fill the form with returned data
|
||||||
data = response.json()
|
data = response.json()
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
request.POST['domain_name'] = str(data)
|
request.POST["domain_name"] = str(data)
|
||||||
else:
|
else:
|
||||||
request.POST['app_id'] = data['id']
|
request.POST["app_id"] = data["id"]
|
||||||
request.POST['client_id'] = data['client_id']
|
request.POST["client_id"] = data["client_id"]
|
||||||
request.POST['client_secret'] = data['client_secret']
|
request.POST["client_secret"] = data["client_secret"]
|
||||||
request.POST['vapid_key'] = data['vapid_key']
|
request.POST["vapid_key"] = data["vapid_key"]
|
||||||
|
|
||||||
|
|
||||||
return super().add_view(request, form_url=form_url, extra_context=extra_context)
|
return super().add_view(request, form_url=form_url, extra_context=extra_context)
|
||||||
|
|
||||||
|
|
|
@ -2,4 +2,4 @@ from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
class MastodonConfig(AppConfig):
|
class MastodonConfig(AppConfig):
|
||||||
name = 'mastodon'
|
name = "mastodon"
|
||||||
|
|
|
@ -3,27 +3,30 @@ from .api import verify_account
|
||||||
|
|
||||||
|
|
||||||
class OAuth2Backend(ModelBackend):
|
class OAuth2Backend(ModelBackend):
|
||||||
""" Used to glue OAuth2 and Django User model """
|
"""Used to glue OAuth2 and Django User model"""
|
||||||
|
|
||||||
# "authenticate() should check the credentials it gets and returns
|
# "authenticate() should check the credentials it gets and returns
|
||||||
# a user object that matches those credentials."
|
# a user object that matches those credentials."
|
||||||
# arg request is an interface specification, not used in this implementation
|
# arg request is an interface specification, not used in this implementation
|
||||||
|
|
||||||
def authenticate(self, request, token=None, username=None, site=None, **kwargs):
|
def authenticate(self, request, token=None, username=None, site=None, **kwargs):
|
||||||
""" when username is provided, assume that token is newly obtained and valid """
|
"""when username is provided, assume that token is newly obtained and valid"""
|
||||||
if token is None or site is None:
|
if token is None or site is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
if username is None:
|
if username is None:
|
||||||
code, user_data = verify_account(site, token)
|
code, user_data = verify_account(site, token)
|
||||||
if code == 200:
|
if code == 200:
|
||||||
userid = user_data['id']
|
userid = user_data["id"]
|
||||||
else:
|
else:
|
||||||
# aquiring user data fail means token is invalid thus auth fail
|
# aquiring user data fail means token is invalid thus auth fail
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# when username is provided, assume that token is newly obtained and valid
|
# when username is provided, assume that token is newly obtained and valid
|
||||||
try:
|
try:
|
||||||
user = UserModel._default_manager.get(mastodon_id=userid, mastodon_site=site)
|
user = UserModel._default_manager.get(
|
||||||
|
mastodon_id=userid, mastodon_site=site
|
||||||
|
)
|
||||||
except UserModel.DoesNotExist:
|
except UserModel.DoesNotExist:
|
||||||
return None
|
return None
|
||||||
else:
|
else:
|
||||||
|
|
|
@ -6,19 +6,17 @@ from requests.exceptions import Timeout
|
||||||
|
|
||||||
|
|
||||||
def mastodon_request_included(func):
|
def mastodon_request_included(func):
|
||||||
""" Handles timeout exception of requests to mastodon, returns http 500 """
|
"""Handles timeout exception of requests to mastodon, returns http 500"""
|
||||||
|
|
||||||
@functools.wraps(func)
|
@functools.wraps(func)
|
||||||
def wrapper(*args, **kwargs):
|
def wrapper(*args, **kwargs):
|
||||||
try:
|
try:
|
||||||
return func(*args, **kwargs)
|
return func(*args, **kwargs)
|
||||||
except (Timeout, ConnectionError):
|
except (Timeout, ConnectionError):
|
||||||
return render(
|
return render(
|
||||||
args[0],
|
args[0], "common/error.html", {"msg": _("联邦网络请求超时叻_(´ཀ`」 ∠)__ ")}
|
||||||
'common/error.html',
|
|
||||||
{
|
|
||||||
'msg': _("联邦网络请求超时叻_(´ཀ`」 ∠)__ ")
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -6,16 +6,18 @@ from users.models import User
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
help = 'Find wrong sites'
|
help = "Find wrong sites"
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
for site in MastodonApplication.objects.all():
|
for site in MastodonApplication.objects.all():
|
||||||
d = site.domain_name
|
d = site.domain_name
|
||||||
login_domain = d.strip().lower().split('//')[-1].split('/')[0].split('@')[-1]
|
login_domain = (
|
||||||
|
d.strip().lower().split("//")[-1].split("/")[0].split("@")[-1]
|
||||||
|
)
|
||||||
domain, version = get_instance_info(login_domain)
|
domain, version = get_instance_info(login_domain)
|
||||||
if d != domain:
|
if d != domain:
|
||||||
print(f'{d} should be {domain}')
|
print(f"{d} should be {domain}")
|
||||||
for u in User.objects.filter(mastodon_site=d, is_active=True):
|
for u in User.objects.filter(mastodon_site=d, is_active=True):
|
||||||
u.mastodon_site = domain
|
u.mastodon_site = domain
|
||||||
print(f'fixing {u}')
|
print(f"fixing {u}")
|
||||||
u.save()
|
u.save()
|
||||||
|
|
|
@ -1,17 +1,21 @@
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
|
|
||||||
def rating_to_emoji(score, star_mode = 0):
|
def rating_to_emoji(score, star_mode=0):
|
||||||
""" convert score to mastodon star emoji code """
|
"""convert score to mastodon star emoji code"""
|
||||||
if score is None or score == '' or score == 0:
|
if score is None or score == "" or score == 0:
|
||||||
return ''
|
return ""
|
||||||
solid_stars = score // 2
|
solid_stars = score // 2
|
||||||
half_star = int(bool(score % 2))
|
half_star = int(bool(score % 2))
|
||||||
empty_stars = 5 - solid_stars if not half_star else 5 - solid_stars - 1
|
empty_stars = 5 - solid_stars if not half_star else 5 - solid_stars - 1
|
||||||
if star_mode == 1:
|
if star_mode == 1:
|
||||||
emoji_code = "🌕" * solid_stars + "🌗" * half_star + "🌑" * empty_stars
|
emoji_code = "🌕" * solid_stars + "🌗" * half_star + "🌑" * empty_stars
|
||||||
else:
|
else:
|
||||||
emoji_code = settings.STAR_SOLID * solid_stars + settings.STAR_HALF * half_star + settings.STAR_EMPTY * empty_stars
|
emoji_code = (
|
||||||
|
settings.STAR_SOLID * solid_stars
|
||||||
|
+ settings.STAR_HALF * half_star
|
||||||
|
+ settings.STAR_EMPTY * empty_stars
|
||||||
|
)
|
||||||
emoji_code = emoji_code.replace("::", ": :")
|
emoji_code = emoji_code.replace("::", ": :")
|
||||||
emoji_code = ' ' + emoji_code + ' '
|
emoji_code = " " + emoji_code + " "
|
||||||
return emoji_code
|
return emoji_code
|
||||||
|
|
|
@ -4,4 +4,4 @@ from .models import *
|
||||||
|
|
||||||
admin.site.register(Report)
|
admin.site.register(Report)
|
||||||
admin.site.register(User)
|
admin.site.register(User)
|
||||||
admin.site.register(Preference)
|
admin.site.register(Preference)
|
||||||
|
|
|
@ -2,4 +2,4 @@ from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
class UsersConfig(AppConfig):
|
class UsersConfig(AppConfig):
|
||||||
name = 'users'
|
name = "users"
|
||||||
|
|
|
@ -3,21 +3,18 @@ from .models import Report
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from common.forms import PreviewImageInput
|
from common.forms import PreviewImageInput
|
||||||
|
|
||||||
|
|
||||||
class ReportForm(forms.ModelForm):
|
class ReportForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Report
|
model = Report
|
||||||
fields = [
|
fields = [
|
||||||
'reported_user',
|
"reported_user",
|
||||||
'image',
|
"image",
|
||||||
'message',
|
"message",
|
||||||
]
|
]
|
||||||
widgets = {
|
widgets = {
|
||||||
'message': forms.Textarea(attrs={'placeholder': _("详情")}),
|
"message": forms.Textarea(attrs={"placeholder": _("详情")}),
|
||||||
'image': PreviewImageInput()
|
"image": PreviewImageInput()
|
||||||
# 'reported_user': forms.TextInput(),
|
# 'reported_user': forms.TextInput(),
|
||||||
}
|
}
|
||||||
labels = {
|
labels = {"reported_user": _("举报的用户"), "image": _("相关证据"), "message": _("详情")}
|
||||||
'reported_user': _("举报的用户"),
|
|
||||||
'image': _("相关证据"),
|
|
||||||
'message': _("详情")
|
|
||||||
}
|
|
||||||
|
|
|
@ -4,16 +4,16 @@ from django.contrib.sessions.models import Session
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
help = 'Backfill Mastodon data if missing'
|
help = "Backfill Mastodon data if missing"
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
for session in Session.objects.order_by('-expire_date'):
|
for session in Session.objects.order_by("-expire_date"):
|
||||||
uid = session.get_decoded().get('_auth_user_id')
|
uid = session.get_decoded().get("_auth_user_id")
|
||||||
token = session.get_decoded().get('oauth_token')
|
token = session.get_decoded().get("oauth_token")
|
||||||
if uid and token:
|
if uid and token:
|
||||||
user = User.objects.get(pk=uid)
|
user = User.objects.get(pk=uid)
|
||||||
if user.mastodon_token:
|
if user.mastodon_token:
|
||||||
print(f'skip {user}')
|
print(f"skip {user}")
|
||||||
continue
|
continue
|
||||||
user.mastodon_token = token
|
user.mastodon_token = token
|
||||||
user.refresh_mastodon_data()
|
user.refresh_mastodon_data()
|
||||||
|
|
|
@ -5,15 +5,15 @@ from django.utils import timezone
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
help = 'disable user'
|
help = "disable user"
|
||||||
|
|
||||||
def add_arguments(self, parser):
|
def add_arguments(self, parser):
|
||||||
parser.add_argument('id', type=int, help='user id')
|
parser.add_argument("id", type=int, help="user id")
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
h = int(options['id'])
|
h = int(options["id"])
|
||||||
u = User.objects.get(id=h)
|
u = User.objects.get(id=h)
|
||||||
u.username = '(duplicated)'+u.username
|
u.username = "(duplicated)" + u.username
|
||||||
u.is_active = False
|
u.is_active = False
|
||||||
u.save()
|
u.save()
|
||||||
print(f'{u} updated')
|
print(f"{u} updated")
|
||||||
|
|
|
@ -6,7 +6,7 @@ from tqdm import tqdm
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
help = 'Refresh following data for all users'
|
help = "Refresh following data for all users"
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
count = 0
|
count = 0
|
||||||
|
@ -14,6 +14,6 @@ class Command(BaseCommand):
|
||||||
user.following = user.get_following_ids()
|
user.following = user.get_following_ids()
|
||||||
if user.following:
|
if user.following:
|
||||||
count += 1
|
count += 1
|
||||||
user.save(update_fields=['following'])
|
user.save(update_fields=["following"])
|
||||||
|
|
||||||
print(f'{count} users updated')
|
print(f"{count} users updated")
|
||||||
|
|
|
@ -6,11 +6,16 @@ from tqdm import tqdm
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
help = 'Refresh Mastodon data for all users if not updated in last 24h'
|
help = "Refresh Mastodon data for all users if not updated in last 24h"
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
count = 0
|
count = 0
|
||||||
for user in tqdm(User.objects.filter(mastodon_last_refresh__lt=timezone.now() - timedelta(hours=24), is_active=True)):
|
for user in tqdm(
|
||||||
|
User.objects.filter(
|
||||||
|
mastodon_last_refresh__lt=timezone.now() - timedelta(hours=24),
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
):
|
||||||
if user.mastodon_token or user.mastodon_refresh_token:
|
if user.mastodon_token or user.mastodon_refresh_token:
|
||||||
tqdm.write(f"Refreshing {user}")
|
tqdm.write(f"Refreshing {user}")
|
||||||
if user.refresh_mastodon_data():
|
if user.refresh_mastodon_data():
|
||||||
|
@ -20,6 +25,6 @@ class Command(BaseCommand):
|
||||||
tqdm.write(f"Refresh failed for {user}")
|
tqdm.write(f"Refresh failed for {user}")
|
||||||
user.save()
|
user.save()
|
||||||
else:
|
else:
|
||||||
tqdm.write(f'Missing token for {user}')
|
tqdm.write(f"Missing token for {user}")
|
||||||
|
|
||||||
print(f'{count} users updated')
|
print(f"{count} users updated")
|
||||||
|
|
Loading…
Add table
Reference in a new issue