/", TopicEntryList.as_view(), name="entry-permalink"),
15 | ]
16 |
--------------------------------------------------------------------------------
/docker/prod/nginx/Dockerfile:
--------------------------------------------------------------------------------
1 | # nginx:1.29-alpine3.22
2 | FROM nginx@sha256:42a516af16b852e33b7682d5ef8acbd5d13fe08fecadc7ed98605ba5e3b26ab8
3 |
4 | COPY docker/prod/nginx /etc/nginx/
5 |
6 | RUN chown -R nginx:nginx /var/cache/nginx && \
7 | chown -R nginx:nginx /var/log/nginx && \
8 | chown -R nginx:nginx /etc/nginx/conf.d && \
9 | chown -R nginx:nginx /etc/nginx/certs
10 | RUN touch /var/run/nginx.pid && \
11 | chown -R nginx:nginx /var/run/nginx.pid
12 |
13 | RUN addgroup -g 1015 fileserv \
14 | && addgroup nginx fileserv
15 |
16 | RUN mkdir -p /app/media && chown -R :fileserv /app/media && chmod -R 770 /app/media
17 | RUN mkdir -p /app/static && chown -R :fileserv /app/static && chmod -R 770 /app/static
18 |
19 | USER nginx
20 |
--------------------------------------------------------------------------------
/docker/dev/dev.Dockerfile:
--------------------------------------------------------------------------------
1 | # 0.8.13-python3.13-alpine
2 | FROM ghcr.io/astral-sh/uv@sha256:3ce89663b5309e77087de25ca805c49988f2716cdb2c6469b1dec2764f58b141
3 |
4 | WORKDIR /app
5 |
6 | ENV PYTHONUNBUFFERED=1 \
7 | PYTHONDONTWRITEBYTECODE=1 \
8 | UV_COMPILE_BYTECODE=1 \
9 | UV_LINK_MODE=copy \
10 | UV_PROJECT_ENVIRONMENT=/usr/local
11 |
12 | RUN --mount=type=cache,target=/var/cache/apk \
13 | --mount=type=cache,target=/etc/apk/cache \
14 | apk update && apk add gcc musl-dev libpq-dev gettext
15 |
16 | RUN --mount=type=cache,target=/root/.cache/uv \
17 | --mount=type=bind,source=uv.lock,target=uv.lock \
18 | --mount=type=bind,source=pyproject.toml,target=pyproject.toml \
19 | uv sync --frozen
20 |
21 | COPY . .
22 |
23 | ENTRYPOINT []
24 |
--------------------------------------------------------------------------------
/dictionary/utils/views.py:
--------------------------------------------------------------------------------
1 | from django.contrib.auth.mixins import PermissionRequiredMixin
2 | from django.core.paginator import EmptyPage, Paginator
3 | from django.views.generic import View
4 |
5 | from dictionary.utils.mixins import IntermediateActionMixin
6 |
7 |
8 | class IntermediateActionView(PermissionRequiredMixin, IntermediateActionMixin, View):
9 | pass
10 |
11 |
12 | class SafePaginator(Paginator):
13 | """
14 | Yields last page if the provided page is bigger than the total number of pages.
15 | """
16 |
17 | def validate_number(self, number):
18 | try:
19 | return super().validate_number(number)
20 | except EmptyPage:
21 | if number > 1:
22 | return self.num_pages
23 | raise
24 |
--------------------------------------------------------------------------------
/dictionary/admin/images.py:
--------------------------------------------------------------------------------
1 | from django.contrib import admin
2 | from django.contrib.admin import DateFieldListFilter
3 |
4 | from dictionary.models import Image
5 |
6 |
7 | @admin.register(Image)
8 | class ImageAdmin(admin.ModelAdmin):
9 | search_fields = ("slug", "author__username", "file")
10 | list_display = ("slug", "author", "date_created", "file", "is_deleted")
11 | list_filter = (("date_created", DateFieldListFilter), "is_deleted")
12 | ordering = ("-date_created",)
13 | readonly_fields = ("author", "file", "date_created")
14 | list_editable = ("is_deleted",)
15 |
16 | def get_queryset(self, request):
17 | queryset = super().get_queryset(request)
18 | return queryset.select_related("author")
19 |
20 | def has_add_permission(self, request, obj=None):
21 | return False
22 |
--------------------------------------------------------------------------------
/dictionary/middleware/csrf.py:
--------------------------------------------------------------------------------
1 | from django.middleware.csrf import CsrfViewMiddleware as _CsrfViewMiddleware, get_token
2 |
3 | # Normally django.middleware.csrf.CsrfViewMiddleware
4 | # ensures 'csrftoken' cookie when there is a form in a
5 | # view (e.g. {% csrf_token %} tag is used and similar cases).
6 |
7 | # This middleware however, ensures that ALL views regardless
8 | # of their content set the csrf cookie. This is required because
9 | # our website dynamically requests the csrftoken cookie.
10 |
11 |
12 | class CsrfViewMiddleware(_CsrfViewMiddleware):
13 | def process_view(self, request, callback, callback_args, callback_kwargs):
14 | retval = super().process_view(request, callback, callback_args, callback_kwargs)
15 | # Force process_response to send the cookie
16 | get_token(request)
17 | return retval
18 |
--------------------------------------------------------------------------------
/dictionary_graph/entry/list.py:
--------------------------------------------------------------------------------
1 | from django.db.models import Q
2 |
3 | from graphene import Int, List, ObjectType
4 |
5 | from dictionary.models import Entry
6 | from dictionary_graph.types import AuthorType
7 | from dictionary_graph.utils import login_required
8 |
9 |
10 | class EntryFavoritesQuery(ObjectType):
11 | favoriters = List(AuthorType, pk=Int(required=True))
12 |
13 | @staticmethod
14 | @login_required
15 | def resolve_favoriters(_parent, info, pk):
16 | return (
17 | Entry.objects_published.get(pk=pk)
18 | .favorited_by(manager="objects_accessible")
19 | .exclude(Q(pk__in=info.context.user.blocked.all()) | Q(pk__in=info.context.user.blocked_by.all()))
20 | .order_by("entryfavorites__date_created")
21 | .only("username", "slug", "is_novice")
22 | )
23 |
--------------------------------------------------------------------------------
/dictionary/admin/sites.py:
--------------------------------------------------------------------------------
1 | from django.conf import settings
2 | from django.contrib import admin
3 | from django.urls import path
4 |
5 | from dictionary.admin.views.sites import ClearCache
6 |
7 |
8 | class SiteAdmin(admin.ModelAdmin):
9 | fields = ("id", "name", "domain")
10 | readonly_fields = ("id",)
11 | list_display = ("id", "name", "domain")
12 | list_display_links = ("name",)
13 |
14 | def get_urls(self):
15 | urls = super().get_urls()
16 | custom_urls = [
17 | path("cache/", self.admin_site.admin_view(ClearCache.as_view()), name="clear-cache"),
18 | ]
19 | return custom_urls + urls
20 |
21 | def has_delete_permission(self, request, obj=None):
22 | if obj and obj.id == settings.SITE_ID:
23 | return False
24 |
25 | return super().has_delete_permission(request, obj)
26 |
--------------------------------------------------------------------------------
/dictionary/templates/dictionary/registration/password_reset/complete.html:
--------------------------------------------------------------------------------
1 | {% extends "dictionary/base.html" %}
2 | {% load i18n %}
3 | {% block title %}{% trans "what was my password" context "titleblock" %}{% endblock %}
4 | {% block content %}
5 | {% trans "what was my password" context "titleblock" %}
6 | {% url 'login' as login_url %}
7 | {% blocktrans %}your password has been changed, use it wisely now. you might want to
log in now.{% endblocktrans %}
8 |
9 |
10 | {% blocktrans trimmed %}
11 | notice: this page was made to inform those who want to reset their password. if you are not
12 | trying to reset your password and somehow found yourself here, you don't need to worry.
13 | {% endblocktrans %}
14 |
15 | {% endblock %}
16 |
--------------------------------------------------------------------------------
/dictionary/templates/dictionary/announcements/post.html:
--------------------------------------------------------------------------------
1 | {% load filters %}
2 |
3 |
4 | {% if not notitle %}
5 |
8 | {% endif %}
9 |
10 | {% if post.html_only %}{{ post.content|safe|linebreaksbr }}{% else %}{{ post.content|formatted|linebreaksbr }}{% endif %}
11 |
12 |
20 |
21 |
--------------------------------------------------------------------------------
/dictionary/admin/category.py:
--------------------------------------------------------------------------------
1 | from django.contrib import admin
2 | from django.contrib.admin import DateFieldListFilter
3 |
4 | from dictionary.models import Category, Suggestion
5 |
6 |
7 | @admin.register(Category)
8 | class CategoryAdmin(admin.ModelAdmin):
9 | search_fields = ("name",)
10 | list_editable = ("weight", "is_pseudo", "is_default")
11 | list_display = ("name", "weight", "description", "is_default", "is_pseudo")
12 | exclude = ("slug",)
13 |
14 |
15 | @admin.register(Suggestion)
16 | class SuggestionAdmin(admin.ModelAdmin):
17 | search_fields = ("author__username", "topic__title")
18 | list_display = ("author", "topic", "category", "direction", "date_created")
19 | list_filter = (
20 | "direction",
21 | "category",
22 | ("date_created", DateFieldListFilter),
23 | )
24 |
25 | def has_change_permission(self, request, obj=None):
26 | return False
27 |
28 | def has_add_permission(self, request):
29 | return False
30 |
--------------------------------------------------------------------------------
/dictionary/management/commands/spam_entries.py:
--------------------------------------------------------------------------------
1 | import random
2 |
3 | from django.utils import timezone
4 |
5 | from dictionary.management.commands import BaseDebugCommand
6 | from dictionary.models import Author, Entry, Topic
7 |
8 | # spam random entries, by random users to (random) topics
9 |
10 |
11 | class Command(BaseDebugCommand):
12 | def handle(self, **options):
13 | topics = None
14 | size = int(input("size: "))
15 | is_random = input("spam to specified topics? y/N: ")
16 |
17 | if is_random == "y":
18 | _range = input("pk range (x,y): ").split(",")
19 | topics = tuple(Topic.objects.filter(pk__range=(_range[0], _range[1])))
20 |
21 | while size > 0:
22 | topic = random.choice(topics) if topics is not None else Topic.objects.order_by("?").first() # nosec
23 | author = Author.objects.filter(is_novice=False).order_by("?").first()
24 | Entry.objects.create(topic=topic, author=author, content=f"{topic}, {author}, {timezone.now()}")
25 | size -= 1
26 |
--------------------------------------------------------------------------------
/dictionary/admin/flatpages.py:
--------------------------------------------------------------------------------
1 | from django.contrib import admin
2 | from django.contrib.flatpages.admin import FlatPageAdmin as _FlatPageAdmin
3 | from django.db import models
4 | from django.forms import Textarea
5 | from django.utils.translation import gettext_lazy as _
6 |
7 | from dictionary.models import ExternalURL
8 |
9 |
10 | class FlatPageAdmin(_FlatPageAdmin):
11 | formfield_overrides = {
12 | models.TextField: {"widget": Textarea(attrs={"rows": 25, "style": "width: 100%; box-sizing: border-box;"})},
13 | }
14 | list_display = ("url", "title", "weight")
15 | list_editable = ("weight",)
16 | fieldsets = (
17 | (None, {"fields": ("url", "title", "content", "html_only", "weight", "sites")}),
18 | (_("Advanced options"), {"classes": ("collapse",), "fields": ("registration_required", "template_name")}),
19 | )
20 | list_filter = ("sites", "registration_required", "html_only")
21 |
22 |
23 | @admin.register(ExternalURL)
24 | class ExternalURLAdmin(admin.ModelAdmin):
25 | list_display = ("name", "url", "weight")
26 | list_editable = ("url", "weight")
27 |
--------------------------------------------------------------------------------
/dictionary/models/m2m.py:
--------------------------------------------------------------------------------
1 | from django.db import models
2 |
3 |
4 | class TopicFollowing(models.Model):
5 | topic = models.ForeignKey("Topic", on_delete=models.CASCADE)
6 | author = models.ForeignKey("Author", on_delete=models.CASCADE)
7 | read_at = models.DateTimeField(auto_now_add=True)
8 | date_created = models.DateTimeField(auto_now_add=True)
9 |
10 |
11 | class EntryFavorites(models.Model):
12 | author = models.ForeignKey("Author", on_delete=models.CASCADE)
13 | entry = models.ForeignKey("Entry", on_delete=models.CASCADE)
14 | date_created = models.DateTimeField(auto_now_add=True)
15 |
16 |
17 | class UpvotedEntries(models.Model):
18 | author = models.ForeignKey("Author", on_delete=models.CASCADE)
19 | entry = models.ForeignKey("Entry", on_delete=models.CASCADE)
20 | date_created = models.DateTimeField(auto_now_add=True)
21 |
22 |
23 | class DownvotedEntries(models.Model):
24 | author = models.ForeignKey("Author", on_delete=models.CASCADE)
25 | entry = models.ForeignKey("Entry", on_delete=models.CASCADE)
26 | date_created = models.DateTimeField(auto_now_add=True)
27 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | WHATEVER := $(wordlist 2,$(words $(MAKECMDGOALS)),$(MAKECMDGOALS))
2 | $(eval $(WHATEVER):;@:)
3 | # ^ Captures all the stuff passed after the target. If you are going
4 | # to pass options, you may do so by using "--" e.g.:
5 | # make up -- --build
6 |
7 | file = docker/dev/docker-compose.yml
8 | ifeq (${CONTEXT}, production)
9 | file = docker/prod/docker-compose.yml
10 | endif
11 |
12 | project = sozluk
13 | cc = docker compose -p $(project) -f $(file)
14 | ex = docker exec -it sozluk-web
15 | dj = $(ex) python manage.py
16 |
17 | .PHONY: *
18 | .DEFAULT_GOAL := detach
19 |
20 | build:
21 | $(cc) build $(WHATEVER)
22 | up:
23 | $(cc) up $(WHATEVER)
24 | detach:
25 | $(cc) up -d $(WHATEVER)
26 | down:
27 | $(cc) down $(WHATEVER)
28 | stop:
29 | $(cc) stop $(WHATEVER)
30 | compose:
31 | $(cc) $(WHATEVER)
32 | logs:
33 | docker logs $(WHATEVER) --tail 500 --follow
34 | console:
35 | $(ex) /bin/bash
36 | run:
37 | $(dj) $(WHATEVER)
38 | shell:
39 | $(dj) shell
40 | test:
41 | $(dj) test --settings=djdict.settings --shuffle --timing --keepdb
42 | format:
43 | pre-commit run
44 | setup:
45 | $(dj) quicksetup
46 |
--------------------------------------------------------------------------------
/dictionary/backends/sessions/db.py:
--------------------------------------------------------------------------------
1 | from django.contrib.auth import get_user_model
2 | from django.contrib.sessions.backends.db import SessionStore as DBStore
3 | from django.contrib.sessions.base_session import AbstractBaseSession
4 | from django.db import models
5 |
6 | User = get_user_model()
7 |
8 |
9 | class PairedSession(AbstractBaseSession):
10 | # Custom session model which stores user foreignkey to associate sessions with particular users.
11 | user = models.ForeignKey(User, null=True, on_delete=models.CASCADE)
12 |
13 | @classmethod
14 | def get_session_store_class(cls):
15 | return SessionStore
16 |
17 |
18 | class SessionStore(DBStore):
19 | @classmethod
20 | def get_model_class(cls):
21 | return PairedSession
22 |
23 | def create_model_instance(self, data):
24 | obj = super().create_model_instance(data)
25 |
26 | try:
27 | user_id = int(data.get("_auth_user_id"))
28 | user = User.objects.get(pk=user_id)
29 | except (ValueError, TypeError, User.DoesNotExist):
30 | user = None
31 | obj.user = user
32 | return obj
33 |
--------------------------------------------------------------------------------
/dictionary/utils/email.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | import hashlib
3 | from uuid import uuid4
4 |
5 | from django.core.mail import EmailMessage
6 | from django.template.loader import render_to_string
7 | from django.utils import timezone
8 | from django.utils.translation import gettext as _
9 |
10 | from dictionary.conf import settings
11 | from dictionary.models import UserVerification
12 |
13 |
14 | def send_email_confirmation(user, to_email):
15 | token = uuid4()
16 | token_hashed = hashlib.blake2b(token.bytes).hexdigest()
17 | expiration_date = timezone.now() + datetime.timedelta(days=1)
18 | UserVerification.objects.create(
19 | author=user, verification_token=token_hashed, expiration_date=expiration_date, new_email=to_email
20 | )
21 |
22 | params = {"domain": settings.DOMAIN, "protocol": settings.PROTOCOL, "user": user, "token": str(token)}
23 | body = render_to_string("dictionary/registration/email/confirmation_email_template.html", params)
24 |
25 | email = EmailMessage(_("e-mail confirmation"), body, settings.FROM_EMAIL, [to_email])
26 | email.content_subtype = "html"
27 | return email.send()
28 |
--------------------------------------------------------------------------------
/dictionary/templates/dictionary/registration/password_reset/done.html:
--------------------------------------------------------------------------------
1 | {% extends "dictionary/base.html" %}
2 | {% load i18n %}
3 |
4 | {% block title %}{% trans "what was my password" context "titleblock" %}{% endblock %}
5 | {% block content %}
6 | {% trans "what was my password" context "titleblock" %}
7 |
8 | {% blocktrans trimmed %}
9 | we sent something that may help you reset your password to the e-mail you entered. it should arrive shortly.
10 | if you don't receive anything, make sure that you entered your e-mail correctly. maybe check your spam
11 | folder, it might be there also.
12 | {% endblocktrans %}
13 |
14 |
15 |
16 | {% blocktrans trimmed %}
17 | notice: this page was made to inform those who want to reset their password. if you are not
18 | trying to reset your password and somehow found yourself here, you don't need to worry.
19 | {% endblocktrans %}
20 |
21 | {% endblock %}
22 |
--------------------------------------------------------------------------------
/dictionary/templates/dictionary/index.html:
--------------------------------------------------------------------------------
1 | {% extends 'dictionary/base.html' %}
2 |
3 | {% load i18n %}
4 |
5 | {% block supertitle %}{{ request.site.name }} - özgür bilgi kaynağı{% endblock %}
6 | {% block bodyclass %} has-entries{% endblock %}
7 | {% block bodyattr %}{% if user.is_authenticated and user.pinned_entry_id %} data-pin="{{ user.pinned_entry_id }}"{% endif %}{% endblock %}
8 | {% block content %}
9 | {% if entries %}
10 |
11 |
12 | {% for entry in entries %}
13 | {% include "dictionary/includes/entry.html" with show_title="yes" gap="3" %}
14 | {% endfor %}
15 |
16 |
17 | {% else %}
18 |
19 | {% trans "no entries found suitable for display 🤷" %}
20 |
21 | {% endif %}
22 |
23 | {% if user.is_authenticated %}
24 | {% include "dictionary/includes/block_user_modal.html" %}
25 | {% include "dictionary/includes/send_message_modal.html" %}
26 | {% endif %}
27 | {% endblock %}
28 |
--------------------------------------------------------------------------------
/dictionary/templates/dictionary/registration/password_reset/form.html:
--------------------------------------------------------------------------------
1 | {% extends "dictionary/base.html" %}
2 | {% load widget_tweaks i18n %}
3 | {% block title %}{% trans "what was my password" context "titleblock" %}{% endblock %}
4 | {% block content %}
5 | {% trans "log in" %} » {% trans "what was my password" context "titleblock" %}
6 |
22 | {% endblock %}
23 |
--------------------------------------------------------------------------------
/docker/prod/nginx/sites-enabled/web.conf:
--------------------------------------------------------------------------------
1 | server {
2 | listen 443 ssl;
3 | listen [::]:443 ssl;
4 | server_name sozluk.me;
5 |
6 | # security
7 | include include/ssl.conf;
8 | include include/security.conf;
9 |
10 | # logging
11 | access_log /var/log/nginx/access.log combined buffer=512k flush=1m;
12 | error_log /var/log/nginx/error.log warn;
13 |
14 | # reverse proxy
15 | location / {
16 | proxy_pass http://web:8000;
17 | proxy_set_header Host $host;
18 | proxy_connect_timeout 60s;
19 | proxy_send_timeout 60s;
20 | proxy_read_timeout 60s;
21 | include include/proxy.conf;
22 | }
23 |
24 | location /static/ {
25 | alias "/app/static/";
26 | expires 90d;
27 | add_header Vary Accept-Encoding;
28 | access_log off;
29 | }
30 |
31 | location /media/ {
32 | internal;
33 | alias "/app/media/";
34 | }
35 |
36 | location = /favicon.ico {
37 | alias "/app/static/dictionary/img/favicon.ico";
38 | }
39 |
40 | # additional config
41 | include include/general.conf;
42 | }
43 |
--------------------------------------------------------------------------------
/dictionary/models/flatpages.py:
--------------------------------------------------------------------------------
1 | from django.contrib.flatpages.models import FlatPage
2 | from django.db import models
3 | from django.utils.translation import gettext_lazy as _
4 |
5 |
6 | class MetaFlatPage(FlatPage):
7 | html_only = models.BooleanField(
8 | default=False,
9 | verbose_name=_("Allow HTML"),
10 | help_text=_("Check this to only use HTML, otherwise you can use entry formatting options."),
11 | )
12 | weight = models.PositiveSmallIntegerField(default=0, verbose_name=_("Weight"))
13 |
14 | class Meta:
15 | ordering = ("-weight", "url")
16 | verbose_name = _("flat page")
17 | verbose_name_plural = _("flat pages")
18 |
19 |
20 | class ExternalURL(models.Model):
21 | name = models.CharField(max_length=64, verbose_name=_("Name"))
22 | url = models.URLField(verbose_name=_("Link"))
23 | weight = models.PositiveSmallIntegerField(default=0, verbose_name=_("Weight"))
24 |
25 | class Meta:
26 | ordering = ("-weight", "name")
27 | verbose_name = _("external url")
28 | verbose_name_plural = _("external urls")
29 |
30 | def __str__(self):
31 | return f"{self.name} -- {self.url}"
32 |
--------------------------------------------------------------------------------
/dictionary/admin/__init__.py:
--------------------------------------------------------------------------------
1 | # flake8: noqa
2 | from django.contrib import admin
3 | from django.contrib.flatpages.models import FlatPage
4 | from django.contrib.sites.models import Site
5 | from django.urls import reverse_lazy
6 | from django.utils.translation import gettext_lazy as _
7 | from django.views.generic.base import RedirectView
8 |
9 | from ..models import MetaFlatPage
10 | from .announcements import AnnouncementAdmin
11 | from .author import AuthorAdmin
12 | from .badge import BadgeAdmin
13 | from .category import CategoryAdmin, SuggestionAdmin
14 | from .entry import CommentAdmin, EntryAdmin
15 | from .flatpages import ExternalURLAdmin, FlatPageAdmin
16 | from .general_report import GeneralReportAdmin
17 | from .images import ImageAdmin
18 | from .sites import SiteAdmin
19 | from .topic import TopicAdmin, WishAdmin
20 |
21 | admin.site.site_header = admin.site.site_title = _("Administration")
22 |
23 | admin.site.login = RedirectView.as_view(url=reverse_lazy("login"))
24 | admin.site.logout = RedirectView.as_view(url=reverse_lazy("logout"))
25 |
26 | admin.site.unregister(FlatPage)
27 | admin.site.register(MetaFlatPage, FlatPageAdmin)
28 |
29 | admin.site.unregister(Site)
30 | admin.site.register(Site, SiteAdmin)
31 |
--------------------------------------------------------------------------------
/dictionary/templates/dictionary/registration/email/resend_form.html:
--------------------------------------------------------------------------------
1 | {% extends "dictionary/base.html" %}
2 | {% load widget_tweaks i18n %}
3 |
4 | {% block title %}{% trans "resend e-mail confirmation" context "titleblock" %}{% endblock %}
5 | {% block content %}
6 |
7 | {% trans "log in" %} » {% trans "resend e-mail confirmation" context "titleblock" %}
8 |
9 | {% if form.non_field_errors %}
10 | {% for error in form.non_field_errors %}
11 | {{ error }}
12 | {% endfor %}
13 | {% endif %}
14 |
15 |
27 | {% endblock %}
28 |
--------------------------------------------------------------------------------
/dictionary/signals/messaging.py:
--------------------------------------------------------------------------------
1 | from django.db.models.signals import m2m_changed, post_save, pre_delete
2 | from django.dispatch import receiver
3 |
4 | from dictionary.models.messaging import Conversation, Message
5 |
6 |
7 | @receiver(post_save, sender=Message, dispatch_uid="deliver_message")
8 | def deliver_message(instance, created, **kwargs):
9 | """
10 | Creates a conversation if user messages the other for the first time.
11 | Adds messages to conversation.
12 | """
13 |
14 | if not created:
15 | return
16 |
17 | holder, _ = instance.sender.conversations.get_or_create(target=instance.recipient)
18 | target, _ = instance.recipient.conversations.get_or_create(target=instance.sender)
19 |
20 | holder.messages.add(instance)
21 | target.messages.add(instance)
22 |
23 |
24 | @receiver(m2m_changed, sender=Conversation.messages.through)
25 | def delete_orphan_messages_individual(action, pk_set, **kwargs):
26 | if action == "post_remove" and not Conversation.objects.filter(messages__in=pk_set).exists():
27 | Message.objects.filter(pk__in=pk_set).delete()
28 |
29 |
30 | @receiver(pre_delete, sender=Conversation)
31 | def delete_orphan_messages_bulk(instance, **kwargs):
32 | instance.messages.remove(*instance.messages.all())
33 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "standard",
3 | "env": {
4 | "browser": true,
5 | "commonjs": true,
6 | "es6": true
7 | },
8 | "parserOptions": {
9 | "ecmaVersion": 2020
10 | },
11 | "rules": {
12 | "semi": [
13 | 2,
14 | "never"
15 | ],
16 | "quotes": [
17 | 2,
18 | "double",
19 | {
20 | "avoidEscape": true,
21 | "allowTemplateLiterals": true
22 | }
23 | ],
24 | "no-use-before-define": [
25 | 2,
26 | {
27 | "functions": true,
28 | "classes": true
29 | }
30 | ],
31 | "object-shorthand": [
32 | 2,
33 | "always"
34 | ],
35 | "arrow-parens": [
36 | 2,
37 | "as-needed"
38 | ],
39 | "prefer-const": [
40 | 2
41 | ],
42 | "dot-notation": [
43 | 2
44 | ],
45 | "func-style": [
46 | 2,
47 | "declaration",
48 | {
49 | "allowArrowFunctions": true
50 | }
51 | ],
52 | "quote-props": [
53 | 2,
54 | "as-needed"
55 | ],
56 | "no-prototype-builtins": [
57 | 2
58 | ],
59 | "indent": [
60 | "warn",
61 | 4,
62 | {
63 | "SwitchCase": 1
64 | }
65 | ],
66 | "object-curly-spacing": [
67 | "warn",
68 | "always"
69 | ]
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/dictionary/templates/500.html:
--------------------------------------------------------------------------------
1 | {% load i18n %}
2 | {% get_current_language as LANGUAGE_CODE %}
3 |
4 |
5 |
6 |
7 |
8 |
9 |
19 | {% trans "server error" context "titleblock" %}
20 |
21 |
22 | {% trans "server error" context "titleblock" %}
23 |
24 | {% blocktrans trimmed %}
25 | sorry, an unexpected error occurred while processing your request. this error has
26 | been recorded and will be revised by the system admins. meanwhile, you may return to
27 | home page as if nothing happened. if you are seeing this error continuously, please
28 | try again in a few moments.
29 | {% endblocktrans %}
30 |
31 |
32 |
33 |
--------------------------------------------------------------------------------
/dictionary/management/commands/quicksetup.py:
--------------------------------------------------------------------------------
1 | from django.contrib.auth.hashers import make_password
2 | from django.core.management import call_command
3 | from django.core.management.base import BaseCommand
4 |
5 | from dictionary.conf import settings
6 | from dictionary.models import Author
7 |
8 |
9 | class Command(BaseCommand):
10 | def handle(self, **options):
11 | call_command("migrate")
12 | call_command("collectstatic", "--noinput")
13 |
14 | attrs = {
15 | "is_active": True,
16 | "is_novice": False,
17 | "application_status": Author.Status.APPROVED,
18 | "message_preference": Author.MessagePref.DISABLED,
19 | "password": make_password(None),
20 | }
21 | Author.objects.get_or_create(
22 | username=settings.GENERIC_PRIVATEUSER_USERNAME,
23 | defaults={
24 | **attrs,
25 | "email": "private@%s" % settings.DOMAIN,
26 | "is_private": True,
27 | },
28 | )
29 | Author.objects.get_or_create(
30 | username=settings.GENERIC_SUPERUSER_USERNAME,
31 | defaults={
32 | **attrs,
33 | "email": "generic@%s" % settings.DOMAIN,
34 | "is_private": False,
35 | },
36 | )
37 |
--------------------------------------------------------------------------------
/dictionary/static/dictionary/img/django_logo_small.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
5 |
6 | ]>
7 |
9 |
10 |
11 |
14 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/dictionary/templates/dictionary/includes/send_message_modal.html:
--------------------------------------------------------------------------------
1 | {% load i18n %}
2 |
25 |
--------------------------------------------------------------------------------
/docker/prod/django/prod.Dockerfile:
--------------------------------------------------------------------------------
1 | # 0.8.13-python3.13-alpine
2 | FROM ghcr.io/astral-sh/uv@sha256:3ce89663b5309e77087de25ca805c49988f2716cdb2c6469b1dec2764f58b141
3 |
4 | ENV UV_COMPILE_BYTECODE=1 \
5 | UV_LINK_MODE=copy
6 |
7 | WORKDIR /app
8 |
9 | RUN --mount=type=cache,target=/var/cache/apk \
10 | --mount=type=cache,target=/etc/apk/cache \
11 | apk update && apk add gcc musl-dev libpq-dev
12 |
13 | RUN --mount=type=cache,target=/root/.cache/uv \
14 | --mount=type=bind,source=uv.lock,target=uv.lock \
15 | --mount=type=bind,source=pyproject.toml,target=pyproject.toml \
16 | uv sync --frozen
17 |
18 | COPY . .
19 |
20 | # python3.13-alpine
21 | FROM python@sha256:9ba6d8cbebf0fb6546ae71f2a1c14f6ffd2fdab83af7fa5669734ef30ad48844
22 |
23 | ENV PYTHONUNBUFFERED=1 \
24 | PYTHONDONTWRITEBYTECODE=1 \
25 | PATH="/app/.venv/bin:$PATH"
26 |
27 | WORKDIR /app
28 |
29 | RUN apk update && apk add --no-cache libpq tini
30 | RUN addgroup -S django \
31 | && addgroup -g 1015 fileserv \
32 | && adduser -S django -G django -G fileserv --disabled-password
33 |
34 | COPY --from=builder --chown=django:django /app /app
35 |
36 | RUN mkdir -p /app/media && chown -R :fileserv /app/media && chmod -R 770 /app/media
37 | RUN mkdir -p /app/static && chown -R :fileserv /app/static && chmod -R 770 /app/static
38 |
39 | USER django
40 | ENTRYPOINT ["/sbin/tini", "--"]
41 |
--------------------------------------------------------------------------------
/dictionary/static/dictionary/img/django_logo_small_dark.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
5 |
6 | ]>
7 |
9 |
10 |
11 |
14 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [project]
2 | name = "django-sozluk"
3 | version = "1.7.0"
4 | requires-python = "==3.13.*"
5 | dependencies = [
6 | "django ~= 5.2",
7 | "django-uuslug ~= 2.0",
8 | "django-widget-tweaks~=1.5",
9 | "graphene-django~=3.2",
10 | "python-dateutil~=2.9",
11 | "user-agents~=2.2",
12 | "pillow~=11.3",
13 | "celery~=5.5",
14 | "django-celery-email~=3.0",
15 | "psycopg[c]~=3.2",
16 | "django-redis~=6.0",
17 | "gunicorn~=23.0"
18 | ]
19 |
20 | [tool.ruff]
21 | fix = true
22 | show-fixes = true
23 | target-version = "py312"
24 | line-length = 120
25 | exclude = ["migrations"]
26 |
27 | [tool.ruff.lint]
28 | fixable = ["I"]
29 | select = [
30 | "E", # pycodestyle errors
31 | "W", # pycodestyle warnings
32 | "F", # pyflakes
33 | "C", # flake8-comprehensions
34 | "B", # flake8-bugbear
35 | "RUF", # Ruff-specific
36 | "C4", # flake8-comprehensions
37 | "C90", # mccabe
38 | "I", # isort
39 | ]
40 | ignore = ["B904", "RUF001", "RUF012","RUF003", "RUF005", "B905"]
41 |
42 | [tool.ruff.lint.isort]
43 | combine-as-imports = true
44 | section-order = [
45 | "future",
46 | "standard-library",
47 | "django",
48 | "third-party",
49 | "first-party",
50 | "local-folder",
51 | ]
52 |
53 | [tool.ruff.lint.isort.sections]
54 | django = ["django"]
55 |
56 | [tool.ruff.lint.mccabe]
57 | max-complexity = 20
58 |
--------------------------------------------------------------------------------
/dictionary/templates/dictionary/includes/paginaton.html:
--------------------------------------------------------------------------------
1 | {% load i18n functions %}
2 | {% with paginator as page_obj %}
3 | {% if page_obj.paginator.num_pages > 1 %}
4 | {% if hr == "yes" %} {% endif %}
5 |
6 |
7 | {% if page_obj.has_previous %}
8 |
«
9 | {% endif %}
10 |
11 |
12 | {{ page_obj.number}}
13 |
14 |
15 |
/
16 |
{{ page_obj.paginator.num_pages }}
17 |
18 | {% if page_obj.has_next %}
19 |
»
20 | {% endif %}
21 |
22 | {% endif %}
23 | {% endwith %}
24 |
--------------------------------------------------------------------------------
/dictionary/middleware/users.py:
--------------------------------------------------------------------------------
1 | from django.db.models import F
2 | from django.utils import timezone
3 |
4 | from dictionary.models import Author
5 | from dictionary.utils import time_threshold
6 |
7 |
8 | class NoviceActivityMiddleware:
9 | """
10 | Novice users who visits the website daily should have advantage on novice
11 | list, so we need to track last active date of novice users.
12 | """
13 |
14 | def __init__(self, get_response):
15 | self.get_response = get_response # One-time configuration and initialization.
16 |
17 | def __call__(self, request):
18 | if (
19 | request.user.is_authenticated
20 | and request.user.is_novice
21 | and request.user.application_status == "PN"
22 | and request.user.is_accessible
23 | ):
24 | last_activity = request.user.last_activity
25 | if last_activity is None or last_activity < time_threshold(hours=24):
26 | Author.objects.filter(id=request.user.id).update(
27 | last_activity=timezone.now(), queue_priority=F("queue_priority") + 1
28 | )
29 | # Code to be executed for each request before
30 | # the view (and later middleware) are called.
31 | response = self.get_response(request)
32 | # Code to be executed for each request/response after
33 | # the view is called.
34 | return response
35 |
--------------------------------------------------------------------------------
/dictionary/admin/views/sites.py:
--------------------------------------------------------------------------------
1 | from django.contrib import admin, messages as notifications
2 | from django.contrib.auth.mixins import PermissionRequiredMixin
3 | from django.contrib.sites.models import Site
4 | from django.core.cache import cache
5 | from django.shortcuts import redirect, reverse
6 | from django.utils.translation import gettext as _
7 | from django.views.generic import TemplateView
8 |
9 | from dictionary.utils.admin import log_admin
10 |
11 |
12 | class ClearCache(PermissionRequiredMixin, TemplateView):
13 | template_name = "admin/sites/clear_cache.html"
14 | permission_required = "dictionary.can_clear_cache"
15 |
16 | def get_context_data(self, **kwargs):
17 | context = super().get_context_data(**kwargs)
18 | context.update(admin.site.each_context(self.request))
19 | context["title"] = _("Clear cache")
20 | return context
21 |
22 | def post(self, request, *args, **kwargs):
23 | if (key := request.POST.get("cache_key") or None) is not None:
24 | message = _("The cache with key '%(key)s' has been invalidated.") % {"key": key}
25 | cache.delete(key)
26 | else:
27 | message = _("All of cache has been invalidated.")
28 | cache.clear()
29 |
30 | log_admin(f"Cleared cache. /cache_key: {key}/", request.user, Site, request.site)
31 | notifications.warning(request, message)
32 | return redirect(reverse("admin:index"))
33 |
--------------------------------------------------------------------------------
/dictionary/templates/dictionary/user/preferences/backup.html:
--------------------------------------------------------------------------------
1 | {% extends "dictionary/user/preferences/index.html" %}
2 | {% load humanize i18n %}
3 |
4 | {% block title %}{% translate "backup" context "titleblock" %}{% endblock %}
5 | {% block content_preferences %}
6 |
7 | {% blocktranslate trimmed %}
8 | you can request a backup file that will include all of your published entries
9 | and archived conversations. the output file will be in JSON format. you can only
10 | create one backup file per day.
11 | {% endblocktranslate %}
12 |
13 |
14 | {% if last_backup %}
15 |
16 | {% if last_backup.is_ready %}
17 | {% url "user_preferences_backup_download" as download_url %}
18 | {% blocktranslate with date=last_backup.date_created %}click here to download the backup file you requested on {{ date }}{% endblocktranslate %}
19 | {% else %}
20 | {% translate "you have a pending backup. you will be notified when its ready." %}
21 | {% endif %}
22 |
23 | {% else %}
24 | {% translate "you have yet to request a backup." %}
25 | {% endif %}
26 |
27 |
28 | {% csrf_token %}
29 | {% translate "create a new backup file" %}
30 |
31 | {% endblock %}
32 |
--------------------------------------------------------------------------------
/dictionary/admin/announcements.py:
--------------------------------------------------------------------------------
1 | from django.contrib import admin
2 | from django.contrib.admin import DateFieldListFilter, SimpleListFilter
3 | from django.db import models
4 | from django.forms import Textarea
5 | from django.utils import timezone
6 | from django.utils.translation import gettext, gettext_lazy as _
7 |
8 | from dictionary.models.announcements import Announcement
9 |
10 |
11 | class PublishFilter(SimpleListFilter):
12 | title = _("Publish status")
13 | parameter_name = "published"
14 |
15 | def lookups(self, request, model_admin):
16 | return [("yes", gettext("Published")), ("no", gettext("Waiting for publication date"))]
17 |
18 | def queryset(self, request, queryset):
19 | if self.value() == "yes":
20 | return queryset.filter(date_created__lte=timezone.now())
21 | if self.value() == "no":
22 | return queryset.filter(date_created__gte=timezone.now())
23 |
24 | return None
25 |
26 |
27 | @admin.register(Announcement)
28 | class AnnouncementAdmin(admin.ModelAdmin):
29 | formfield_overrides = {
30 | models.TextField: {"widget": Textarea(attrs={"rows": 20, "style": "width: 50%; box-sizing: border-box;"})},
31 | }
32 |
33 | autocomplete_fields = ("discussion",)
34 | search_fields = ("title",)
35 | list_filter = (PublishFilter, ("date_created", DateFieldListFilter), "html_only", "notify")
36 | list_display = ("title", "discussion", "date_created")
37 | ordering = ("-date_created",)
38 |
--------------------------------------------------------------------------------
/dictionary/templates/dictionary/announcements/base.html:
--------------------------------------------------------------------------------
1 | {% extends "dictionary/base.html" %}
2 | {% load humanize filters i18n %}
3 |
4 | {% block title %}{% trans "announcements" context "titleblock" %}{% endblock %}
5 |
6 | {% block content %}
7 |
11 |
12 | {% block innercontent %}
13 | {% if latest.exists %}
14 | {% for post in latest %}
15 | {% include "dictionary/announcements/post.html" %}
16 | {% endfor %}
17 | {% include "dictionary/includes/paginaton.html" with paginator=page_obj %}
18 | {% else %}
19 | {% trans "there are no announcements yet." %}
20 | {% endif %}
21 | {% endblock %}
22 | {% endblock %}
23 |
24 | {% block rightframe %}
25 | {% if date_list.exists %}
26 |
27 | {% trans "announcement history" %}
28 |
35 |
36 | {% endif %}
37 | {% endblock %}
38 |
--------------------------------------------------------------------------------
/dictionary/models/managers/messaging.py:
--------------------------------------------------------------------------------
1 | from contextlib import suppress
2 |
3 | from django.db import models
4 | from django.db.models import Count, Max, Q
5 |
6 |
7 | class MessageManager(models.Manager):
8 | def compose(self, sender, recipient, body):
9 | if not sender.can_send_message(recipient):
10 | return False
11 |
12 | has_receipt = sender.allow_receipts and recipient.allow_receipts
13 | message = self.create(sender=sender, recipient=recipient, body=body, has_receipt=has_receipt)
14 | return message
15 |
16 |
17 | class ConversationManager(models.Manager):
18 | def list_for_user(self, user, search_term=None):
19 | # List conversation list for user, provide search_term to search in messages
20 |
21 | if search_term:
22 | base = self.filter(
23 | Q(holder=user)
24 | & (Q(messages__body__icontains=search_term) | Q(messages__recipient__username__icontains=search_term)),
25 | )
26 | else:
27 | base = self.filter(holder=user)
28 |
29 | return base.annotate(
30 | message_sent_last=Max("messages__sent_at"),
31 | unread_count=Count("messages", filter=Q(messages__recipient=user, messages__read_at__isnull=True)),
32 | ).order_by("-message_sent_last")
33 |
34 | def with_user(self, sender, recipient):
35 | with suppress(self.model.DoesNotExist):
36 | return sender.conversations.get(target=recipient)
37 |
38 | return None
39 |
--------------------------------------------------------------------------------
/dictionary/templates/dictionary/list/image_list.html:
--------------------------------------------------------------------------------
1 | {% extends "dictionary/base.html" %}
2 | {% load humanize i18n %}
3 |
4 | {% block title %}{% trans "my images" context "titleblock" %}{% endblock %}
5 |
6 | {% block content %}
7 | {% trans "my images" context "titleblock" %}
8 |
9 |
10 | {% with images=object_list %}
11 | {% if images %}
12 | {% for image in images %}
13 |
14 |
15 |
22 |
23 | {% endfor %}
24 | {% else %}
25 | {% trans "sorry, but couldn't find anything 🤷" %}
26 | {% endif %}
27 | {% endwith %}
28 |
29 | {% endblock %}
30 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2019-2024, Şuayip Üzülmez
2 | All rights reserved.
3 |
4 | Redistribution and use in source and binary forms, with or without
5 | modification, are permitted provided that the following conditions are met:
6 |
7 | 1. Redistributions of source code must retain the above copyright notice, this
8 | list of conditions and the following disclaimer.
9 |
10 | 2. Redistributions in binary form must reproduce the above copyright notice,
11 | this list of conditions and the following disclaimer in the documentation
12 | and/or other materials provided with the distribution.
13 |
14 | 3. Neither the name of the copyright holder nor the names of its
15 | contributors may be used to endorse or promote products derived from
16 | this software without specific prior written permission.
17 |
18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
19 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
20 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
28 |
--------------------------------------------------------------------------------
/docker/dev/docker-compose.yml:
--------------------------------------------------------------------------------
1 | volumes:
2 | pg-data:
3 | mq-data:
4 |
5 | services:
6 | db:
7 | container_name: sozluk-postgres
8 | # postgres:17.6-alpine3.22
9 | image: postgres@sha256:3406990b6e4c7192317b6fdc5680498744f6142f01f0287f4ee0420d8c74063c
10 | user: postgres
11 | env_file: ../../conf/dev/postgres.env
12 | ports:
13 | - "5432:5432"
14 | volumes:
15 | - pg-data:/var/lib/postgresql/data
16 |
17 | redis:
18 | container_name: sozluk-redis
19 | # redis:8.2-alpine3.22
20 | image: redis@sha256:987c376c727652f99625c7d205a1cba3cb2c53b92b0b62aade2bd48ee1593232
21 | user: redis
22 |
23 | rabbitmq:
24 | container_name: sozluk-rabbitmq
25 | # rabbitmq:4.1-alpine
26 | image: rabbitmq@sha256:a6dbb0d4e409a371b0c4baa0cc844903be8702ad29e7917fd7f3d19207cb468e
27 | hostname: rabbitmq_local
28 | volumes:
29 | - mq-data:/var/lib/rabbitmq
30 |
31 | web:
32 | container_name: sozluk-web
33 | build:
34 | context: ../..
35 | dockerfile: docker/dev/dev.Dockerfile
36 | command: python manage.py runserver 0.0.0.0:8000
37 | ports:
38 | - "8000:8000"
39 | extends:
40 | file: common.yml
41 | service: python
42 |
43 | celery-worker: &celery
44 | container_name: sozluk-celery-worker
45 | command: celery -A djdict worker -l info
46 | depends_on:
47 | - db
48 | - redis
49 | - rabbitmq
50 | extends:
51 | file: common.yml
52 | service: python
53 |
54 | celery-beat:
55 | <<: *celery
56 | container_name: sozluk-celery-beat
57 | command: celery -A djdict beat -l info
58 |
--------------------------------------------------------------------------------
/dictionary/templates/404.html:
--------------------------------------------------------------------------------
1 | {% extends "dictionary/base.html" %}
2 | {% load i18n %}
3 |
4 | {% block title %}{% trans "page not found" context "titleblock" %}{% endblock %}
5 |
6 | {% block content %}
7 | {% url "home" as home_url %}
8 | {% url "topic_list" "search" as search_url %}
9 |
10 | {% autoescape off %}
11 | {% blocktrans asvar navigation_message trimmed %}
12 | you may return to
13 | home page or keep looking using
14 | advanced search .
15 | {% endblocktrans %}
16 |
17 | {% if request.resolver_match.view_name == "entry-permalink" %}
18 | {% trans "entry not found" %}
19 |
20 | {% blocktrans trimmed with entry_id=request.resolver_match.kwargs.entry_id %}
21 | entry with identity (#{{ entry_id }}) was not found.
22 | it might have been deleted and could be lurking in the limbo now, who knows?
23 | {{ navigation_message }}
24 | {% endblocktrans %}
25 |
26 | {% else %}
27 | {% trans "page not found" context "titleblock" %}
28 |
29 | {% blocktrans trimmed %}
30 | the page you requested was not found.
31 | {{ navigation_message }}
32 | {% endblocktrans %}
33 |
34 | {% endif %}
35 | {% endautoescape %}
36 | {% endblock %}
37 |
--------------------------------------------------------------------------------
/dictionary/models/reporting.py:
--------------------------------------------------------------------------------
1 | from uuid import uuid4
2 |
3 | from django.db import models
4 | from django.utils.translation import gettext_lazy as _
5 |
6 |
7 | class GeneralReport(models.Model):
8 | class CategoryPref(models.TextChoices):
9 | CONTENT = "CNT", _("about the some content published")
10 | OTHER = "ETC", _("about other subjects")
11 |
12 | reporter_email = models.EmailField(verbose_name=_("e-mail"))
13 | category = models.CharField(
14 | max_length=3,
15 | choices=CategoryPref.choices,
16 | verbose_name=_("category"),
17 | default=CategoryPref.CONTENT,
18 | )
19 | subject = models.CharField(max_length=160, verbose_name=_("Subject"))
20 | content = models.TextField(verbose_name=_("Content"))
21 | is_open = models.BooleanField(
22 | default=True,
23 | verbose_name=_("Report is open"),
24 | help_text=_("Indicates the current status of the report."),
25 | )
26 |
27 | key = models.UUIDField(default=uuid4, unique=True, editable=False)
28 | is_verified = models.BooleanField(
29 | default=False,
30 | verbose_name=_("Verified"),
31 | help_text=_("Indicates whether this report has been verified by e-mail"),
32 | )
33 |
34 | date_created = models.DateTimeField(auto_now_add=True, verbose_name=_("Date created"))
35 | date_verified = models.DateTimeField(null=True, verbose_name=_("Date verified"))
36 |
37 | class Meta:
38 | verbose_name = _("report")
39 | verbose_name_plural = _("reports")
40 |
41 | def __str__(self):
42 | return f"{self.subject} <{self.__class__.__name__}>#{self.pk}"
43 |
--------------------------------------------------------------------------------
/dictionary/templates/dictionary/includes/profile_entry_pinned.html:
--------------------------------------------------------------------------------
1 | {% load filters i18n %}
2 |
28 |
--------------------------------------------------------------------------------
/dictionary/models/images.py:
--------------------------------------------------------------------------------
1 | import secrets
2 | import string
3 | import uuid
4 |
5 | from django.db import models
6 | from django.urls import reverse
7 | from django.utils.translation import gettext_lazy as _
8 |
9 |
10 | def user_directory_path(instance, filename):
11 | ext = filename.split(".")[-1]
12 | return f"images/{instance.author.pk}/{uuid.uuid4().hex}.{ext}"
13 |
14 |
15 | def image_slug():
16 | """
17 | Assigns a slug to an image. (Tries again recursively if the slug is taken.)
18 | """
19 |
20 | slug = "".join(secrets.choice(string.ascii_lowercase + string.digits) for _i in range(8))
21 |
22 | try:
23 | Image.objects.get(slug=slug)
24 | return image_slug()
25 | except Image.DoesNotExist:
26 | return slug
27 |
28 |
29 | class Image(models.Model):
30 | author = models.ForeignKey("Author", null=True, on_delete=models.SET_NULL, verbose_name=_("Author"))
31 | file = models.ImageField(upload_to=user_directory_path, verbose_name=_("File"))
32 | slug = models.SlugField(default=image_slug, unique=True, editable=False)
33 | is_deleted = models.BooleanField(default=False, verbose_name=_("Unpublished"))
34 | date_created = models.DateTimeField(auto_now_add=True, verbose_name=_("Date created"))
35 |
36 | class Meta:
37 | verbose_name = _("image")
38 | verbose_name_plural = _("images")
39 |
40 | def __str__(self):
41 | return str(self.slug)
42 |
43 | def delete(self, *args, **kwargs):
44 | super().delete()
45 | self.file.delete(save=False)
46 |
47 | def get_absolute_url(self):
48 | return reverse("image-detail", kwargs={"slug": self.slug})
49 |
--------------------------------------------------------------------------------
/dictionary/templates/dictionary/includes/forms/wish.html:
--------------------------------------------------------------------------------
1 | {% load humanize filters i18n %}
2 |
3 | {% with wishes=topic.wish_collection %}
4 |
14 | {% endwith %}
15 |
16 |
17 | {% if user.is_authenticated %}
18 |
26 | {% endif %}
27 |
--------------------------------------------------------------------------------
/dictionary/urls/user.py:
--------------------------------------------------------------------------------
1 | from django.conf import settings
2 | from django.urls import path
3 |
4 | from dictionary.views.detail import Chat, ChatArchive, UserProfile
5 | from dictionary.views.edit import UserPreferences
6 | from dictionary.views.images import ImageDetailDevelopment, ImageDetailProduction, ImageList, ImageUpload
7 | from dictionary.views.list import ActivityList, ConversationArchiveList, ConversationList, PeopleList
8 |
9 | ImageDetailView = ImageDetailDevelopment if settings.DEBUG else ImageDetailProduction
10 | """
11 | This should be set to ImageDetailProduction if your media files served outside
12 | Django. (Check ImageDetailProduction view for info)
13 | """
14 |
15 | urlpatterns_user = [
16 | # user related urls
17 | path("settings/", UserPreferences.as_view(), name="user_preferences"),
18 | path("people/", PeopleList.as_view(), name="people"),
19 | path("people//", PeopleList.as_view(), name="people-tab"),
20 | path("activity/", ActivityList.as_view(), name="activity"),
21 | path("messages/", ConversationList.as_view(), name="messages"),
22 | path("messages/archive/", ConversationArchiveList.as_view(), name="messages-archive"),
23 | path("messages//", Chat.as_view(), name="conversation"),
24 | path("messages/archive//", ChatArchive.as_view(), name="conversation-archive"),
25 | path("author//", UserProfile.as_view(), name="user-profile"),
26 | path("author///", UserProfile.as_view(), name="user-profile-stats"),
27 | path("myimages/", ImageList.as_view(), name="image-list"),
28 | path("upload/", ImageUpload.as_view(), name="image-upload"),
29 | path("img//", ImageDetailView.as_view(), name="image-detail"),
30 | ]
31 |
--------------------------------------------------------------------------------
/dictionary/templates/admin/actions/unsuspend_user.html:
--------------------------------------------------------------------------------
1 | {% extends "admin/base_site.html" %}
2 | {% load i18n %}
3 | {% block breadcrumbs %}
4 |
14 | {% endblock %}
15 |
16 | {% block content %}
17 | {% trans "You have selected these users to unsuspend:" %}
18 |
19 | {% for source in sources %}
20 |
21 |
22 | {{ source.username }}
23 |
24 | ({% trans "suspension expiration date:" %} {{ source.suspended_until }})
25 |
26 | {% endfor %}
27 |
28 |
29 | {% trans "Are you sure?" %}
30 |
38 | {% endblock %}
39 |
--------------------------------------------------------------------------------
/dictionary/templates/admin/novice_lookup.html:
--------------------------------------------------------------------------------
1 | {% extends "admin/base_site.html" %}
2 | {% load i18n %}
3 |
4 | {% block breadcrumbs %}
5 |
13 | {% endblock %}
14 | {% block content %}
15 | {% for entry in entries %}
16 |
20 | {{ entry.content }} {{ entry.date_created }}
21 |
22 |
23 | {% endfor %}
24 |
25 |
26 |
27 | {% trans "Operation:" %}
28 |
29 | {% trans "Select operation" %}
30 | {% trans "Approve authorship" %}
31 | {% trans "Reject authorship and purge all entries" %}
32 |
33 |
34 | {% trans "Apply and go back" %}
35 |
36 | {% csrf_token %}
37 |
38 | {% endblock %}
39 |
--------------------------------------------------------------------------------
/dictionary/static/dictionary/js/user.js:
--------------------------------------------------------------------------------
1 | /* global gettext */
2 |
3 | import { Handle, Handler, gqlc, notify, toggleText } from "./utils"
4 | import { showBlockDialog, showMessageDialog } from "./dialog"
5 |
6 | function userAction (type, recipient, loc = null, re = true) {
7 | return gqlc({ query: `mutation{user{${type}(username:"${recipient}"){feedback redirect}}}` }).then(function (response) {
8 | const info = response.data.user[type]
9 | if (re && (loc || info.redirect)) {
10 | window.location = loc || info.redirect
11 | } else {
12 | notify(info.feedback)
13 | }
14 | })
15 | }
16 |
17 | Handler(".block-user-trigger", "click", function () {
18 | const sync = this.classList.contains("sync")
19 | const recipient = this.getAttribute("data-username")
20 |
21 | if (sync) {
22 | return userAction("block", recipient, location)
23 | }
24 |
25 | userAction("block", recipient, null, false).then(function () {
26 | toggleText(this, gettext("remove block"), gettext("block this guy"))
27 | }.bind(this))
28 | })
29 |
30 | Handler(".follow-user-trigger", "click", function () {
31 | const targetUser = this.parentNode.getAttribute("data-username")
32 | userAction("follow", targetUser)
33 | toggleText(this.querySelector("a"), gettext("follow"), gettext("unfollow"))
34 | })
35 |
36 | Handle("ul.user-links", "click", function (event) {
37 | const recipient = this.getAttribute("data-username")
38 | if (event.target.matches("li.block-user a")) {
39 | showBlockDialog(recipient, true, event.target)
40 | } else if (event.target.matches("li.send-message a")) {
41 | showMessageDialog(recipient, null, event.target)
42 | }
43 | })
44 |
45 | export { userAction }
46 |
--------------------------------------------------------------------------------
/djdict/urls.py:
--------------------------------------------------------------------------------
1 | """djdict URL Configuration
2 |
3 | The `urlpatterns` list routes URLs to views. For more information please see:
4 | https://docs.djangoproject.com/en/2.1/topics/http/urls/
5 | Examples:
6 | Function views
7 | 1. Add an import: from my_app import views
8 | 2. Add a URL to urlpatterns: path('', views.home, name='home')
9 | Class-based views
10 | 1. Add an import: from other_app.views import Home
11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
12 | Including another URLconf
13 | 1. Import the include() function: from django.urls import include, path
14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
15 | """
16 |
17 | from django.contrib import admin
18 | from django.contrib.sitemaps import views as sitemap_views
19 | from django.urls import include, path
20 | from django.views.decorators.gzip import gzip_page
21 | from django.views.i18n import JavaScriptCatalog
22 |
23 | from dictionary.sitemaps import sitemaps
24 |
25 | urlpatterns = [
26 | path("", include("dictionary.urls")),
27 | path("graphql/", include("dictionary_graph.urls")),
28 | path("admin/", admin.site.urls),
29 | # i18n
30 | path("jsi18n/", JavaScriptCatalog.as_view(packages=["dictionary"]), name="javascript-catalog"),
31 | path("i18n/", include("django.conf.urls.i18n")),
32 | # Sitemap
33 | path("sitemap.xml", gzip_page(sitemap_views.index), {"sitemaps": sitemaps}),
34 | path(
35 | "sitemap-.xml",
36 | gzip_page(sitemap_views.sitemap),
37 | {"sitemaps": sitemaps},
38 | name="django.contrib.sitemaps.views.sitemap",
39 | ),
40 | ]
41 |
42 | # Will consider this near release:
43 | # https://docs.djangoproject.com/en/3.0/topics/i18n/translation/#note-on-performance
44 |
--------------------------------------------------------------------------------
/dictionary/views/announcements.py:
--------------------------------------------------------------------------------
1 | from django.utils import timezone
2 | from django.views.generic.dates import ArchiveIndexView, DateDetailView, MonthArchiveView
3 |
4 | from dictionary.models import Announcement
5 |
6 |
7 | class AnnouncementMixin:
8 | model = Announcement
9 | date_field = "date_created"
10 | paginate_by = 10
11 |
12 | def get_queryset(self):
13 | return super().get_queryset().select_related("discussion")
14 |
15 |
16 | class AnnouncementIndex(AnnouncementMixin, ArchiveIndexView):
17 | template_name = "dictionary/announcements/index.html"
18 | allow_empty = True
19 | date_list_period = "month"
20 |
21 | def dispatch(self, request, *args, **kwargs):
22 | # Mark announcements read
23 | if request.user.is_authenticated and request.user.unread_topic_count["announcements"] > 0:
24 | request.user.announcement_read = timezone.now()
25 | request.user.save()
26 | self.request.user.invalidate_unread_topic_count()
27 | del request.user.unread_topic_count # reset cached_property
28 |
29 | return super().dispatch(request, *args, **kwargs)
30 |
31 |
32 | class AnnouncementMonth(AnnouncementMixin, MonthArchiveView):
33 | template_name = "dictionary/announcements/month.html"
34 | date_list_period = "month"
35 | context_object_name = "latest"
36 |
37 | def get_date_list(self, queryset, **kwargs):
38 | # To list ALL months in date_list (the archive doesn't go deeper at this point)
39 | return super().get_date_list(queryset=self.model.objects.all(), ordering="DESC", **kwargs)
40 |
41 |
42 | class AnnouncementDetail(AnnouncementMixin, DateDetailView):
43 | template_name = "dictionary/announcements/detail.html"
44 | context_object_name = "post"
45 |
--------------------------------------------------------------------------------
/dictionary/templates/admin/novices.html:
--------------------------------------------------------------------------------
1 | {% extends "admin/base_site.html" %}
2 | {% load i18n %}
3 | {% block content %}
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 | {% for obj in object_list %}
30 |
31 | {{ obj.username }}
32 | {{ obj.email }}
33 | {{ obj.application_date }}
34 | {{ obj.date_joined }}
35 |
36 | {% endfor %}
37 |
38 |
39 |
40 |
41 | {% trans "current number of users in novice list:" %} {{ novice_count }}
42 | {% endblock %}
43 |
--------------------------------------------------------------------------------
/dictionary/static/dictionary/js/common.js:
--------------------------------------------------------------------------------
1 | import { Handle, Handler, template, updateQueryStringParameter } from "./utils"
2 | import { isTouchDevice } from "./mql"
3 |
4 | Handle("body", "change", event => {
5 | if (event.target.matches("select.page-selector")) {
6 | window.location = updateQueryStringParameter(location.href, "page", event.target.value)
7 | }
8 | })
9 |
10 | Handle("body", isTouchDevice ? "touchstart" : "focusin", event => {
11 | // Load pages for paginator select
12 | const select = event.target
13 | if (select.matches("select.page-selector") && !select.hasAttribute("data-loaded")) {
14 | const max = parseInt(select.getAttribute("data-max"), 10)
15 | const current = parseInt(select.value, 10)
16 | select.firstElementChild.remove()
17 |
18 | for (let i = 1; i <= max; i++) {
19 | const option = template(`${i} `)
20 | select.append(option)
21 | }
22 |
23 | select.setAttribute("data-loaded", "")
24 | }
25 | })
26 |
27 | Handle("form.search_mobile, form.reporting-form", "submit", function () {
28 | Array.from(this.querySelectorAll("input")).filter(input => {
29 | if (input.type === "checkbox" && !input.checked) {
30 | return true
31 | }
32 | return input.value === ""
33 | }).forEach(input => {
34 | input.disabled = true
35 | })
36 | return false
37 | })
38 |
39 | Handler("[data-toggle=collapse]", "click", function () {
40 | this.closest("div").parentNode.querySelector(".collapse").classList.toggle("show")
41 | if (this.getAttribute("aria-expanded") === "false") {
42 | this.setAttribute("aria-expanded", "true")
43 | } else {
44 | this.setAttribute("aria-expanded", "false")
45 | }
46 | })
47 |
--------------------------------------------------------------------------------
/dictionary/templates/dictionary/includes/block_user_modal.html:
--------------------------------------------------------------------------------
1 | {% load i18n %}
2 |
3 |
4 |
5 |
11 |
12 |
13 | {% trans "are you sure about blocking this user? you will be devoid from some things:" %}
14 |
15 |
16 | {% trans "you won't be able to send messages to this user (vice versa)" %}
17 | {% trans "you won't be able to visit the profile of this user (vice versa)" %}
18 | {% trans "topics created by this user won't be visible in your today list" %}
19 | {% trans "you won't be able to see the entries of this user" %}
20 |
21 |
22 |
23 |
27 |
28 |
29 |
30 |
--------------------------------------------------------------------------------
/dictionary/templates/dictionary/list/category_list.html:
--------------------------------------------------------------------------------
1 | {% extends "dictionary/base.html" %}
2 | {% load i18n %}
3 |
4 | {% block title %}{% trans "channels" context "titleblock" %}{% endblock %}
5 | {% block content %}
6 | {% trans "channels" context "titleblock" %}
7 |
8 | {% url "topic_list" "today" as today_url %}
9 | {% url 'topic_list' "uncategorized" as uncategorized_url %}
10 |
11 | {% blocktrans trimmed with today_attr=user.is_authenticated|yesno:'data-lf-slug="today",'|safe uncategorized_attr='data-lf-slug="uncategorized"' %}
12 | the complete list of channels that classify the topics with general subjects. in
13 | today only the topics with channels you follow and
14 | topics with no channels are listed.
15 | {% endblocktrans %}
16 |
17 |
18 | {% for category in categories %}
19 |
20 |
21 |
24 | {{ category.description }}
25 |
26 | {% if user.is_authenticated %}
27 | {% trans "unfollow,follow" context "category-list" as state %}
28 | {{ category.is_followed|yesno:state }}
29 | {% endif %}
30 |
31 | {% endfor %}
32 |
33 | {% endblock %}
34 |
--------------------------------------------------------------------------------
/dictionary_graph/autocomplete.py:
--------------------------------------------------------------------------------
1 | from functools import wraps
2 |
3 | from django.db.models import Q
4 |
5 | from graphene import Int, List, ObjectType, String
6 |
7 | from dictionary.models import Author, Topic
8 | from dictionary_graph.types import AuthorType, TopicType
9 |
10 |
11 | def autocompleter(resolver):
12 | """Utility decorator to validate lookup and limit arguments"""
13 |
14 | @wraps(resolver)
15 | def decorator(parent, info, lookup, limit=7, **kwargs):
16 | lookup, limit = lookup.strip(), limit if limit and 1 <= limit <= 7 else 7
17 | return resolver(parent, info, lookup, limit, **kwargs) if lookup else None
18 |
19 | return decorator
20 |
21 |
22 | class AuthorAutoCompleteQuery(ObjectType):
23 | authors = List(AuthorType, lookup=String(required=True), limit=Int())
24 |
25 | @staticmethod
26 | @autocompleter
27 | def resolve_authors(_parent, info, lookup, limit):
28 | queryset = Author.objects_accessible.filter(username__istartswith=lookup).only("username", "slug", "is_novice")
29 |
30 | if info.context.user.is_authenticated:
31 | blocked = info.context.user.blocked.all()
32 | blocked_by = info.context.user.blocked_by.all()
33 | return queryset.exclude(Q(pk__in=blocked) | Q(pk__in=blocked_by))[:limit]
34 |
35 | return queryset[:limit]
36 |
37 |
38 | class TopicAutoCompleteQuery(ObjectType):
39 | topics = List(TopicType, lookup=String(required=True), limit=Int())
40 |
41 | @staticmethod
42 | @autocompleter
43 | def resolve_topics(_parent, _info, lookup, limit):
44 | return Topic.objects_published.filter(
45 | Q(title__istartswith=lookup) | Q(title__icontains=lookup), is_censored=False
46 | ).only("title")[:limit]
47 |
48 |
49 | class AutoCompleteQueries(AuthorAutoCompleteQuery, TopicAutoCompleteQuery):
50 | """Inherits the queries of word completion"""
51 |
--------------------------------------------------------------------------------
/dictionary_graph/schema.py:
--------------------------------------------------------------------------------
1 | from graphene import Field, ObjectType, Schema
2 |
3 | from .autocomplete import AutoCompleteQueries
4 | from .category import CategoryMutations
5 | from .entry import EntryMutations, EntryQueries
6 | from .images import ImageMutations
7 | from .messaging import MessageMutations
8 | from .topic import TopicMutations, TopicQueries
9 | from .user import UserMutations
10 |
11 |
12 | class Query(TopicQueries, ObjectType):
13 | # This class will include multiple Queries
14 | # as we begin to add more apps to our project
15 | autocomplete = Field(AutoCompleteQueries)
16 | entry = Field(EntryQueries)
17 |
18 | @staticmethod
19 | def resolve_autocomplete(*args, **kwargs):
20 | return AutoCompleteQueries()
21 |
22 | @staticmethod
23 | def resolve_entry(*args, **kwargs):
24 | return EntryQueries()
25 |
26 |
27 | class Mutation(ObjectType):
28 | # This class will include multiple Mutations
29 | # as we begin to add more apps to our project
30 | message = Field(MessageMutations)
31 | user = Field(UserMutations)
32 | topic = Field(TopicMutations)
33 | category = Field(CategoryMutations)
34 | entry = Field(EntryMutations)
35 | image = Field(ImageMutations)
36 |
37 | @staticmethod
38 | def resolve_message(*args, **kwargs):
39 | return MessageMutations()
40 |
41 | @staticmethod
42 | def resolve_user(*args, **kwargs):
43 | return UserMutations()
44 |
45 | @staticmethod
46 | def resolve_topic(*args, **kwargs):
47 | return TopicMutations()
48 |
49 | @staticmethod
50 | def resolve_category(*args, **kwargs):
51 | return CategoryMutations()
52 |
53 | @staticmethod
54 | def resolve_entry(*args, **kwargs):
55 | return EntryMutations()
56 |
57 | @staticmethod
58 | def resolve_image(*args, **kwargs):
59 | return ImageMutations()
60 |
61 |
62 | schema = Schema(query=Query, mutation=Mutation)
63 |
--------------------------------------------------------------------------------
/dictionary/templates/dictionary/conversation/inbox_archive.html:
--------------------------------------------------------------------------------
1 | {% extends "dictionary/conversation/inbox_base.html" %}
2 | {% load filters humanize i18n %}
3 |
4 | {% block title %}{% trans "conversation archive" context "titleblock" %}{% endblock %}
5 |
6 | {% block innercontent %}
7 | {% if conversations %}
8 |
9 | {% for object in conversations %}
10 | {% with lastmsg=object.to_json.messages|last %}
11 |
12 |
13 |
20 | {{ lastmsg.body|truncatechars:225 }}
21 |
22 |
23 |
24 |
27 | {{ lastmsg.sent_at|strdate|naturalday }}
28 |
29 |
30 | {% endwith %}
31 | {% endfor %}
32 |
33 | {% else %}
34 | {% trans "your archive is empty 🤔" %}
35 | {% endif %}
36 | {% endblock %}
37 |
--------------------------------------------------------------------------------
/dictionary/utils/validators.py:
--------------------------------------------------------------------------------
1 | from django.core.validators import ValidationError, _lazy_re_compile
2 | from django.utils.html import mark_safe
3 | from django.utils.translation import gettext as _
4 |
5 | from uuslug import slugify
6 |
7 | from dictionary.conf import settings
8 |
9 | user_text_re = _lazy_re_compile(r"^[A-Za-z0-9 ğçıöşüĞÇİÖŞÜ#&@()_+=':%/\",.!?*~`\[\]{}<>^;\\|-]+$")
10 | topic_title_re = _lazy_re_compile(r"^[a-z0-9 ğçıöşü()_+='%/\",.!?~\[\]{}<>^;\\|-]+$")
11 |
12 |
13 | def validate_topic_title(value, exctype=ValidationError):
14 | if not slugify(value):
15 | raise exctype(_("that title is just too nasty."))
16 |
17 | if len(value) > 50:
18 | raise exctype(_("this title is too long"))
19 |
20 | if topic_title_re.fullmatch(value) is None:
21 | raise exctype(_("the definition of this topic includes forbidden characters"))
22 |
23 |
24 | def validate_user_text(value, exctype=ValidationError):
25 | if len(value.strip()) < 1:
26 | raise exctype(_("my dear, just write your entry, how hard could it be?"))
27 |
28 | if user_text_re.fullmatch("".join(value.splitlines())) is None:
29 | args, kwargs = [_("this content includes forbidden characters.")], {}
30 |
31 | if exctype == ValidationError:
32 | kwargs["params"] = {"value": value}
33 |
34 | raise exctype(*args, **kwargs)
35 |
36 |
37 | def validate_category_name(value):
38 | if slugify(value) in settings.NON_DB_CATEGORIES:
39 | message = _(
40 | "The channel couldn't be created as the name of this channel"
41 | " clashes with a reserved category lister. The complete list"
42 | " of forbidden names follows:"
43 | )
44 | raise ValidationError(mark_safe(f"{message} {' '.join(settings.NON_DB_CATEGORIES)}"))
45 |
46 |
47 | def validate_username_partial(value):
48 | if slugify(value) == "archive":
49 | raise ValidationError(_("this nickname is reserved by the boss"))
50 |
--------------------------------------------------------------------------------
/dictionary/templates/admin/sites/clear_cache.html:
--------------------------------------------------------------------------------
1 | {% extends "admin/base_site.html" %}
2 | {% load i18n %}
3 | {% block breadcrumbs %}
4 |
5 |
{% trans "Home" %}
6 | {% block crumbs %}
7 | {% if title %} › {{ title }}{% endif %}
8 | {% endblock %}
9 |
10 | {% endblock %}
11 |
12 | {% block content %}
13 |
14 |
15 |
16 | {% blocktrans trimmed %}
17 | You can use this utility to clear expired/invalid objects from cache. You may specify
18 | a cache key to delete a specific object. If you leave the cache key blank, ALL
19 | of the objects from the cache will be deleted. If the site has high traffic, flushing all the
20 | cache is likely to increase the number of incoming requests.
21 | {% endblocktrans %}
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 | {% csrf_token %}
37 |
38 |
39 | {% endblock %}
40 |
--------------------------------------------------------------------------------
/dictionary/admin/general_report.py:
--------------------------------------------------------------------------------
1 | from django.contrib import admin, messages as notifications
2 | from django.contrib.admin import DateFieldListFilter
3 | from django.utils.translation import gettext, gettext_lazy as _
4 |
5 | from dictionary.models import GeneralReport
6 |
7 |
8 | @admin.register(GeneralReport)
9 | class GeneralReportAdmin(admin.ModelAdmin):
10 | search_fields = ("subject", "reporter_email")
11 | list_display = ("subject", "reporter_email", "category", "date_created", "is_open")
12 | readonly_fields = (
13 | "reporter_email",
14 | "category",
15 | "subject",
16 | "content",
17 | "date_created",
18 | "date_verified",
19 | "is_verified",
20 | )
21 | fieldsets = (
22 | (_("Report content"), {"fields": ("category", "subject", "content")}),
23 | (_("Metadata"), {"fields": ("reporter_email", "date_created", "is_verified", "date_verified")}),
24 | (_("Evaluation"), {"fields": ("is_open",)}),
25 | )
26 | list_per_page = 30
27 | list_filter = ("category", "is_open", ("date_created", DateFieldListFilter))
28 | list_editable = ("is_open",)
29 | ordering = ("-is_open",)
30 | actions = ("close_report", "open_report")
31 |
32 | def get_queryset(self, request):
33 | return super().get_queryset(request).exclude(is_verified=False)
34 |
35 | def close_report(self, request, queryset):
36 | queryset.update(is_open=False)
37 | notifications.success(request, gettext("Selected reports' status were marked as closed."))
38 |
39 | def open_report(self, request, queryset):
40 | queryset.update(is_open=True)
41 | notifications.success(request, gettext("Selected reports' status were marked as open."))
42 |
43 | close_report.short_description = _("Mark selected reports' status as closed.")
44 | open_report.short_description = _("Mark selected reports' status as open.")
45 |
46 | def has_add_permission(self, request, obj=None):
47 | return False
48 |
--------------------------------------------------------------------------------
/dictionary/admin/entry.py:
--------------------------------------------------------------------------------
1 | from django.contrib import admin
2 | from django.contrib.admin import DateFieldListFilter
3 | from django.utils.translation import gettext_lazy as _
4 |
5 | from dictionary.models import Comment, Entry
6 |
7 |
8 | @admin.register(Entry)
9 | class EntryAdmin(admin.ModelAdmin):
10 | search_fields = ("id", "author__username", "topic__title")
11 | autocomplete_fields = ("topic",)
12 | list_display = ("id", "topic", "author", "vote_rate")
13 | list_filter = (
14 | ("date_created", DateFieldListFilter),
15 | ("date_edited", DateFieldListFilter),
16 | "is_draft",
17 | "author__is_novice",
18 | "topic__category",
19 | )
20 | ordering = ("-date_created",)
21 | fieldsets = (
22 | (None, {"fields": ("author", "topic", "content", "vote_rate")}),
23 | (_("Metadata"), {"fields": ("is_draft", "date_created", "date_edited")}),
24 | )
25 |
26 | readonly_fields = ("author", "content", "vote_rate", "is_draft", "date_created", "date_edited")
27 |
28 | def has_add_permission(self, request, obj=None):
29 | return False
30 |
31 |
32 | def topic_title(obj):
33 | return obj.entry.topic.title
34 |
35 |
36 | def entry_content(obj):
37 | return obj.entry.content
38 |
39 |
40 | topic_title.short_description = _("Topic")
41 | entry_content.short_description = _("Entry")
42 |
43 |
44 | @admin.register(Comment)
45 | class CommentAdmin(admin.ModelAdmin):
46 | autocomplete_fields = ("entry", "author")
47 | search_fields = ("author__username", "id")
48 | fields = ("author", topic_title, entry_content, "content")
49 | list_display = ("author", topic_title, "id", "date_created")
50 | list_filter = (
51 | ("date_created", DateFieldListFilter),
52 | ("date_edited", DateFieldListFilter),
53 | )
54 | ordering = ("-date_created",)
55 |
56 | def has_change_permission(self, request, obj=None):
57 | return False
58 |
59 | def has_add_permission(self, request, obj=None):
60 | return False
61 |
--------------------------------------------------------------------------------
/dictionary/utils/admin.py:
--------------------------------------------------------------------------------
1 | from functools import wraps
2 |
3 | from django.contrib.admin.models import CHANGE, LogEntry
4 | from django.contrib.contenttypes.models import ContentType
5 | from django.shortcuts import redirect, reverse
6 |
7 | # Admin site specific utilities
8 |
9 |
10 | def log_admin(msg, authorizer, model_type, model_object, flag=CHANGE):
11 | LogEntry.objects.log_action(
12 | user_id=authorizer.id,
13 | content_type_id=ContentType.objects.get_for_model(model_type).pk,
14 | object_id=model_object.id,
15 | object_repr=str(model_object),
16 | change_message=msg,
17 | action_flag=flag,
18 | )
19 |
20 |
21 | def logentry_instance(msg, authorizer, model_type, model_object, flag=CHANGE):
22 | return LogEntry(
23 | user_id=authorizer.pk,
24 | content_type=ContentType.objects.get_for_model(model_type),
25 | object_id=model_object.pk,
26 | object_repr=str(model_object),
27 | change_message=msg,
28 | action_flag=flag,
29 | )
30 |
31 |
32 | def logentry_bulk_create(*logentry_instances):
33 | LogEntry.objects.bulk_create(*logentry_instances)
34 |
35 |
36 | class IntermediateActionHandler:
37 | def __init__(self, queryset, url_name):
38 | self.queryset = queryset
39 | self.url_name = url_name
40 |
41 | def get_source_list(self):
42 | return "-".join(map(str, self.queryset.values_list("id", flat=True)))
43 |
44 | @property
45 | def redirect_url(self):
46 | return redirect(reverse(self.url_name) + f"?source_list={self.get_source_list()}")
47 |
48 |
49 | def intermediate(action):
50 | """
51 | Decorator for admin actions with intermediate pages (IntermediateActionView).
52 | The decorated action should return the name of the url.
53 | """
54 |
55 | @wraps(action)
56 | def decorator(model_admin, request, queryset):
57 | view_name = action(model_admin, request, queryset)
58 | handler = IntermediateActionHandler(queryset, view_name)
59 | return handler.redirect_url
60 |
61 | return decorator
62 |
--------------------------------------------------------------------------------
/docker/prod/docker-compose.yml:
--------------------------------------------------------------------------------
1 | volumes:
2 | pg-data:
3 | mq-data:
4 | static:
5 | media:
6 |
7 | services:
8 | db:
9 | container_name: sozluk-postgres
10 | # postgres:17.6-alpine3.22
11 | image: postgres@sha256:3406990b6e4c7192317b6fdc5680498744f6142f01f0287f4ee0420d8c74063c
12 | user: postgres
13 | env_file: ../../conf/prod/postgres.env
14 | restart: unless-stopped
15 | volumes:
16 | - pg-data:/var/lib/postgresql/data
17 |
18 | redis:
19 | container_name: sozluk-redis
20 | # redis:8.2-alpine3.22
21 | image: redis@sha256:987c376c727652f99625c7d205a1cba3cb2c53b92b0b62aade2bd48ee1593232
22 | user: redis
23 | restart: unless-stopped
24 |
25 | rabbitmq:
26 | container_name: sozluk-rabbitmq
27 | # rabbitmq:4.1-alpine
28 | image: rabbitmq@sha256:a6dbb0d4e409a371b0c4baa0cc844903be8702ad29e7917fd7f3d19207cb468e
29 | hostname: rabbitmq_sozluk
30 | volumes:
31 | - mq-data:/var/lib/rabbitmq
32 | restart: unless-stopped
33 |
34 | nginx:
35 | container_name: sozluk-nginx
36 | build:
37 | context: ../..
38 | dockerfile: docker/prod/nginx/Dockerfile
39 | ports:
40 | - "443:443"
41 | restart: unless-stopped
42 | volumes:
43 | - static:/app/static
44 | - media:/app/media
45 |
46 | web:
47 | container_name: sozluk-web
48 | build:
49 | context: ../..
50 | dockerfile: docker/prod/django/prod.Dockerfile
51 | command: >
52 | gunicorn
53 | djdict.wsgi
54 | --bind 0.0.0.0:8000
55 | --workers 4
56 | --access-logfile '-'
57 | extends:
58 | file: django/common.yml
59 | service: python
60 |
61 | celery-worker: &celery
62 | container_name: sozluk-celery-worker
63 | command: celery -A djdict worker -l info
64 | depends_on:
65 | - rabbitmq
66 | - db
67 | - redis
68 | extends:
69 | file: django/common.yml
70 | service: python
71 |
72 | celery-beat:
73 | <<: *celery
74 | container_name: sozluk-celery-beat
75 | command: celery -A djdict beat -l info -s /app/dictionary/celerybeat-schedule
76 |
--------------------------------------------------------------------------------
/dictionary_graph/entry/edit.py:
--------------------------------------------------------------------------------
1 | from django.template.defaultfilters import linebreaksbr
2 | from django.utils import timezone
3 | from django.utils.translation import gettext as _
4 |
5 | from graphene import ID, Mutation, String
6 |
7 | from dictionary.models import Entry, Topic
8 | from dictionary.templatetags.filters import formatted
9 | from dictionary.utils.validators import validate_user_text
10 | from dictionary_graph.utils import login_required
11 |
12 |
13 | class DraftEdit(Mutation):
14 | pk = ID()
15 | content = String()
16 | feedback = String()
17 |
18 | class Arguments:
19 | content = String()
20 | pk = ID(required=False)
21 | title = String(required=False)
22 |
23 | @staticmethod
24 | @login_required
25 | def mutate(_root, info, content, pk=None, title=None):
26 | validate_user_text(content, exctype=ValueError)
27 |
28 | if pk:
29 | entry = Entry.objects_all.get(is_draft=True, author=info.context.user, pk=pk)
30 | entry.content = content
31 | entry.date_edited = timezone.now()
32 | entry.save(update_fields=["content", "date_edited"])
33 | return DraftEdit(
34 | pk=entry.pk,
35 | content=linebreaksbr(formatted(entry.content)),
36 | feedback=_("your changes have been saved as draft"),
37 | )
38 |
39 | if title:
40 | topic = Topic.objects.get_or_pseudo(unicode_string=title)
41 |
42 | if (topic.exists and topic.is_banned) or not topic.valid:
43 | raise ValueError(_("we couldn't handle your request. try again later."))
44 |
45 | if not topic.exists:
46 | topic = Topic.objects.create_topic(title=topic.title)
47 |
48 | entry = Entry(author=info.context.user, topic=topic, content=content, is_draft=True)
49 | entry.save()
50 | return DraftEdit(
51 | pk=entry.pk,
52 | content=linebreaksbr(formatted(entry.content)),
53 | feedback=_("your entry has been saved as draft"),
54 | )
55 |
56 | raise ValueError(_("we couldn't handle your request. try again later."))
57 |
--------------------------------------------------------------------------------
/dictionary/middleware/frontend.py:
--------------------------------------------------------------------------------
1 | from django.utils import timezone
2 |
3 | from user_agents import parse
4 |
5 | from dictionary.conf import settings
6 | from dictionary.utils import get_theme_from_cookie
7 | from dictionary.utils.context_processors import lf_proxy
8 |
9 |
10 | class MobileDetectionMiddleware:
11 | # Simple middleware to detect if the user is using a mobile device.
12 | def __init__(self, get_response):
13 | self.get_response = get_response # One-time configuration and initialization.
14 |
15 | def __call__(self, request):
16 | if request.user.is_authenticated:
17 | theme = request.user.theme
18 | else:
19 | theme = get_theme_from_cookie(request)
20 |
21 | ua_string = request.headers.get("User-Agent", "")
22 | user_agent = parse(ua_string)
23 |
24 | request.is_mobile = user_agent.is_mobile
25 | request.theme = theme
26 |
27 | # Code to be executed for each request before
28 | # the view (and later middleware) are called.
29 | response = self.get_response(request)
30 |
31 | if request.user.is_authenticated and get_theme_from_cookie(request) != theme:
32 | response.set_cookie("theme", theme, samesite="Lax", expires=timezone.now() + timezone.timedelta(days=90))
33 |
34 | # Code to be executed for each request/response after
35 | # the view is called.
36 | return response
37 |
38 |
39 | class LeftFrameMiddleware:
40 | """Injects left frame to context data."""
41 |
42 | def __init__(self, get_response):
43 | self.get_response = get_response
44 |
45 | def __call__(self, request):
46 | response = self.get_response(request)
47 | # Add default category to response cookies, if no category
48 | # is already selected.
49 | active_category = request.COOKIES.get("lfac")
50 | if active_category is None:
51 | response.set_cookie("lfac", settings.DEFAULT_CATEGORY)
52 | return response
53 |
54 | def process_template_response(self, request, response):
55 | response.context_data["left_frame"] = lf_proxy(request, response) if not request.is_mobile else {}
56 | return response
57 |
--------------------------------------------------------------------------------
/dictionary/static/dictionary/js/accessibility.js:
--------------------------------------------------------------------------------
1 | /* global gettext */
2 |
3 | import { Handle, Handler, isValidText, notify } from "./utils"
4 |
5 | Handle("body", "keypress", event => {
6 | if (event.target.matches("[role=button], .key-clickable") && (event.key === " " || event.key === "Enter")) {
7 | event.preventDefault()
8 | event.target.dispatchEvent(new Event("click", { bubbles: true }))
9 | }
10 | })
11 |
12 | Handle(".content-skipper", "click", function () {
13 | location.replace(this.getAttribute("data-href"))
14 | })
15 |
16 | Handler("input.is-invalid", "input", function () {
17 | this.classList.remove("is-invalid")
18 | })
19 |
20 | Handler("textarea.expandable", "focus", function () {
21 | this.style.height = `${this.offsetHeight + 150}px`
22 | Handle(this, "transitionend", () => {
23 | this.style.transition = "none"
24 | })
25 | }, { once: true })
26 |
27 | const irregularCharMap = {
28 | "\u201C": `"`,
29 | "\u201D": `"`,
30 | "\u2018": `'`,
31 | "\u2019": `'`,
32 | "\u02C6": `^` // eslint-disable-line
33 | }
34 |
35 | function normalizeChars (string) {
36 | return String(string).replace(/[\u201C\u201D\u2018\u2019\u02C6]/g, function (s) {
37 | return irregularCharMap[s]
38 | })
39 | }
40 |
41 | Handler("textarea#user_content_edit, textarea#message-body", "input", function () {
42 | window.onbeforeunload = () => this.value || null
43 | this.value = normalizeChars(this.value)
44 | })
45 |
46 | Handler("textarea.normalize", "input", function () {
47 | this.value = normalizeChars(this.value)
48 | })
49 |
50 | Handler("form", "submit", function (event) {
51 | window.onbeforeunload = null
52 |
53 | if (this.id === "header_search_form" && !new FormData(this).get("q").trim()) {
54 | event.preventDefault()
55 | window.location = "/threads/search/"
56 | }
57 |
58 | const userInput = this.querySelector("#user_content_edit")
59 |
60 | if (userInput && userInput.value) {
61 | if (!isValidText(userInput.value)) {
62 | notify(gettext("this content includes forbidden characters."), "error")
63 | window.onbeforeunload = () => true
64 | event.preventDefault()
65 | return false
66 | }
67 | }
68 | })
69 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ## django-sozluk, ekşi sözlük clone powered by Python
2 |
3 | [](LICENSE)
4 |
5 | Demo website is now available at [sozluk.me](https://sozluk.me/) \
6 | Check [CHANGELOG](CHANGELOG) before cloning a newer version!
7 |
8 | This is a clone of ekşi sözlük. Commonly referred as "collaborative
9 | dictionary", this type of social networking can be thought as "urban dictionary
10 | on steroids". Visit
11 | [this Wikipedia article](https://en.wikipedia.org/wiki/Ek%C5%9Fi_S%C3%B6zl%C3%BCk)
12 | to learn more about this type of social network.
13 |
14 | **This project is currently maintained.** If you want to contribute to the
15 | project or have found a bug or need help about deployment
16 | etc., [create an issue](https://github.com/realsuayip/django-sozluk/issues/new).
17 |
18 | Check out [screenshots](screenshots) folder to see current front-end in action
19 | with both the desktop and mobile views.
20 |
21 | ### Deployment Guide
22 |
23 | #### Requirements
24 |
25 | 1. Have Docker, with Compose plugin (v2) installed in your system.
26 | 2. Have GNU make installed in your system.
27 | 3. Have your SSL certificates and dhparam file under `docker/prod/nginx/certs`.
28 | They should be named exactly as following: `server.crt`, `server.key`
29 | and `dhparam.pem`
30 | 4. Change and configure secrets in `django.env` and `postgres.env` files
31 | under `conf/prod`
32 | 5. Configure your preferences in `dictionary/apps.py`
33 |
34 | #### Deployment
35 |
36 | > [!IMPORTANT]
37 | > When running any `make` command make sure `CONTEXT` environment variable is
38 | > set to `production`
39 |
40 | **In the project directory, run this command:**
41 |
42 | ```shell
43 | CONTEXT=production make
44 | ```
45 |
46 | At this point, your server will start serving requests via https port (443).
47 | You should see a 'server error' page when you navigate to your website.
48 |
49 | **To complete the installation, you need to run a initialization script:**
50 |
51 | ```shell
52 | CONTEXT=production make setup
53 | ```
54 |
55 | After running this command, you should be able to navigate through your website
56 | without any issues. At this point, you should create an administrator account
57 | to log in and manage your website:
58 |
59 | ```shell
60 | CONTEXT=production make run createsuperuser
61 | ```
62 |
--------------------------------------------------------------------------------
/dictionary/templates/dictionary/user/preferences/password.html:
--------------------------------------------------------------------------------
1 | {% extends "dictionary/user/preferences/index.html" %}
2 | {% load widget_tweaks i18n filters %}
3 | {% block title %}{% trans "password change" context "titleblock" %}{% endblock %}
4 |
5 | {% block content_preferences %}
6 | {% if form.errors %}
7 |
8 | {% trans "please try again after checking specified errors." %}
9 |
10 | {% endif %}
11 |
12 |
13 | {{ form.non_field_erros }}
14 | {% with WIDGET_ERROR_CLASS='is-invalid' %}
15 |
16 | {{ form.old_password.label|i18n_lower }}
17 | {% render_field form.old_password class="form-control" id="pref_old_password" %}
18 | {% for error in form.old_password.errors %}
19 | {{ error|i18n_lower }}
20 | {% endfor %}
21 |
22 |
23 |
24 | {{ form.new_password1.label|i18n_lower }}
25 | {% render_field form.new_password1 class="form-control" id="pref_new_password1" %}
26 | {% for error in form.new_password1.errors %}
27 | {{ error|i18n_lower }}
28 | {% endfor %}
29 | {{ form.new_password1.help_text|i18n_lower|safe }}
30 |
31 |
32 |
33 | {{ form.new_password2.label|i18n_lower }}
34 | {% render_field form.new_password2 class="form-control" id="pref_new_password2" %}
35 | {% for error in form.new_password2.errors %}
36 | {{ error|i18n_lower }}
37 | {% endfor %}
38 |
39 | {% endwith %}
40 |
41 | {% csrf_token %}
42 | {% trans "change password" %}
43 |
44 | {% endblock %}
45 |
--------------------------------------------------------------------------------
/dictionary/static/dictionary/js/search.js:
--------------------------------------------------------------------------------
1 | import { userIsMobile } from "./mql"
2 | import { LeftFrame } from "./left-frame"
3 | import { Handle, Handler, one } from "./utils"
4 |
5 | function dictToParameters (dict) {
6 | const str = []
7 | for (const key in dict) {
8 | // a. check if the property/key is defined in the object itself, not in parent
9 | // b. check if the key is not empty
10 | if (Object.prototype.hasOwnProperty.call(dict, key) && dict[key]) {
11 | str.push(encodeURIComponent(key) + "=" + encodeURIComponent(dict[key]))
12 | }
13 | }
14 | return str.join("&")
15 | }
16 |
17 | function populateSearchResults (searchParameters) {
18 | if (!searchParameters) {
19 | return
20 | }
21 |
22 | const slug = "search"
23 |
24 | if (userIsMobile) {
25 | window.location = `/threads/${slug}/?${searchParameters}`
26 | }
27 | LeftFrame.populate(slug, 1, null, searchParameters)
28 | }
29 |
30 | Handle("button#perform_advanced_search", "click", () => {
31 | const favoritesElement = one("input#in_favorites_dropdown")
32 |
33 | const keywords = one("input#keywords_dropdown").value
34 | const authorNick = one("input#author_nick_dropdown").value
35 | const isNiceOnes = one("input#nice_ones_dropdown").checked
36 | const isFavorites = favoritesElement && favoritesElement.checked
37 | const fromDate = one("input#date_from_dropdown").value
38 | const toDate = one("input#date_to_dropdown").value
39 | const ordering = one("select#ordering_dropdown").value
40 |
41 | const keys = {
42 | keywords,
43 | author_nick: authorNick,
44 | is_nice_ones: isNiceOnes,
45 | is_in_favorites: isFavorites,
46 | from_date: fromDate,
47 | to_date: toDate,
48 | ordering
49 | }
50 | populateSearchResults(dictToParameters(keys))
51 | })
52 |
53 | Handler("body", "click", event => {
54 | if (event.target.matches("a[role=button].quicksearch")) {
55 | const term = event.target.getAttribute("data-keywords")
56 | let parameter
57 | if (term.startsWith("@") && term.substr(1)) {
58 | parameter = `author_nick=${term.substr(1)}`
59 | } else {
60 | parameter = `keywords=${term}`
61 | }
62 | const searchParameters = parameter + "&ordering=newer"
63 | populateSearchResults(searchParameters)
64 | }
65 | })
66 |
--------------------------------------------------------------------------------
/dictionary/templates/admin/app_list.html:
--------------------------------------------------------------------------------
1 | {% load i18n %}
2 |
3 | {% if app_list %}
4 | {% for app in app_list %}
5 |
6 |
7 |
8 | {{ app.name }}
9 |
10 | {% for model in app.models %}
11 |
12 | {% if model.admin_url %}
13 | {{ model.name }}
14 | {% else %}
15 | {{ model.name }}
16 | {% endif %}
17 |
18 | {% if model.add_url %}
19 | {% translate 'Add' %}
20 | {% else %}
21 |
22 | {% endif %}
23 |
24 | {% if model.admin_url and show_changelinks %}
25 | {% if model.view_only %}
26 | {% translate 'View' %}
27 | {% else %}
28 | {% translate 'Change' %}
29 | {% endif %}
30 | {% elif show_changelinks %}
31 |
32 | {% endif %}
33 |
34 | {% endfor %}
35 |
36 |
37 | {% endfor %}
38 | {% else %}
39 | {% translate 'You don’t have permission to view or edit anything.' %}
40 | {% endif %}
41 |
42 |
55 |
--------------------------------------------------------------------------------
/dictionary/templates/dictionary/registration/login.html:
--------------------------------------------------------------------------------
1 | {% extends "dictionary/base.html" %}
2 | {% load widget_tweaks i18n filters %}
3 |
4 | {% block title %}{% trans "log in" context "titleblock" %}{% endblock %}
5 | {% block content %}
6 | {% trans "log in" context "titleblock" %}
7 | {% if messages %}
8 | {% for message in messages %}
9 | {{ message }}
10 | {% endfor %}
11 | {% endif %}
12 |
13 | {% if user.is_authenticated %}
14 | {% blocktrans with name=user.username %}just to let you know, you are already logged in as '{{ name }}'.{% endblocktrans %}
15 | {% endif %}
16 |
17 |
18 | {% csrf_token %}
19 |
20 |
30 |
31 |
38 |
39 |
40 | {% render_field form.remember_me class="custom-control-input" id="remember_me" %}
41 | {{ form.remember_me.label }}
42 |
43 |
44 |
48 |
49 | {% trans "log in" %}
50 |
51 | {% endblock %}
52 |
--------------------------------------------------------------------------------
/dictionary/templates/dictionary/user/preferences/email.html:
--------------------------------------------------------------------------------
1 | {% extends "dictionary/user/preferences/index.html" %}
2 | {% load widget_tweaks i18n %}
3 | {% block title %}{% trans "change e-mail" context "titleblock" %}{% endblock %}
4 |
5 | {% block content_preferences %}
6 |
7 | {% if form.non_field_errors %}
8 | {% for error in form.non_field_errors %}
9 | {{ error }}
10 | {% endfor %}
11 | {% endif %}
12 |
13 |
14 | {% trans "current e-mail in use:" %}
15 | {{ user.email }}
16 |
17 |
18 | {% if not user.email_confirmed %}
19 |
20 | {% blocktrans trimmed %}
21 | a confirmation link has been sent to your new e-mail address. by following
22 | this link you can complete your e-mail change. if you didn't receive any mails,
23 | you can fill the form below again.
24 | {% endblocktrans %}
25 |
26 | {% endif %}
27 |
28 |
29 |
37 |
38 |
46 |
47 |
48 | {{ form.password_confirm.label }}
49 | {% render_field form.password_confirm id="password_confirm" class="form-control" %}
50 |
51 | {% csrf_token %}
52 |
53 | {% trans "a confirmation link will be sent to the new e-mail address." %}
54 | {% trans "change e-mail" %}
55 |
56 | {% endblock %}
57 |
--------------------------------------------------------------------------------
/dictionary/templates/dictionary/conversation/conversation_archive.html:
--------------------------------------------------------------------------------
1 | {% extends "dictionary/base.html" %}
2 | {% load filters humanize i18n %}
3 |
4 |
5 | {% block title %}{% blocktrans with username=object.target context "titleblock" %}@{{ username }} - conversation archive{% endblocktrans %}{% endblock %}
6 |
7 | {% block content %}
8 |
9 | {% blocktrans trimmed with username=object.target %}
10 | archived conversation with @{{ username }}
11 | {% endblocktrans %}
12 |
13 |
14 |
33 |
34 |
49 | {% endblock %}
50 |
--------------------------------------------------------------------------------
/dictionary/templates/dictionary/includes/editor_buttons.html:
--------------------------------------------------------------------------------
1 | {% load i18n %}
2 |
3 |
4 | {# Translators: Editor button (see) #}
5 | {% trans "(see: thingy)" %}
6 | {% trans "thingy" %}
7 | ∗
8 | {% trans "--spoiler--" %}
9 | http://
10 |
11 | {% if not user.is_novice %}
12 |
{% trans "image" context "editor" %}
13 | {% endif %}
14 |
37 |
38 |
--------------------------------------------------------------------------------
/dictionary/tasks.py:
--------------------------------------------------------------------------------
1 | from datetime import timedelta
2 |
3 | from django.contrib.auth.models import Permission
4 | from django.db.models import Count, Q
5 |
6 | from dictionary.conf import settings
7 | from dictionary.models import AccountTerminationQueue, Author, BackUp, GeneralReport, Image, UserVerification
8 | from dictionary.utils import time_threshold
9 | from djdict import celery_app
10 |
11 |
12 | @celery_app.task
13 | def process_backup(backup_id):
14 | BackUp.objects.get(id=backup_id).process()
15 |
16 |
17 | # Periodic tasks
18 |
19 |
20 | @celery_app.on_after_finalize.connect
21 | def setup_periodic_tasks(sender, **kwargs):
22 | """Add and set intervals of periodic tasks."""
23 | sender.add_periodic_task(timedelta(hours=4), commit_user_deletions)
24 | sender.add_periodic_task(timedelta(hours=6), purge_images)
25 | sender.add_periodic_task(timedelta(hours=12), purge_verifications)
26 | sender.add_periodic_task(timedelta(hours=14), purge_reports)
27 | sender.add_periodic_task(timedelta(hours=16), grant_perm_suggestion)
28 |
29 |
30 | @celery_app.task
31 | def purge_verifications():
32 | """Delete expired verifications."""
33 | UserVerification.objects.filter(expiration_date__lte=time_threshold(hours=24)).delete()
34 |
35 |
36 | @celery_app.task
37 | def purge_reports():
38 | """Delete expired reports."""
39 | GeneralReport.objects.filter(is_verified=False, date_created__lte=time_threshold(hours=24)).delete()
40 |
41 |
42 | @celery_app.task
43 | def purge_images():
44 | """Delete expired images (Not bulk deleting so as to delete actual image files)."""
45 | expired = Image.objects.filter(is_deleted=True, date_created__lte=time_threshold(hours=120))
46 | for image in expired:
47 | image.delete()
48 |
49 |
50 | @celery_app.task
51 | def commit_user_deletions():
52 | """Delete (marked) users."""
53 | AccountTerminationQueue.objects.commit_terminations()
54 |
55 |
56 | @celery_app.task
57 | def grant_perm_suggestion():
58 | """Gives suitable users 'dictionary.can_suggest_categories' permission."""
59 |
60 | perm = Permission.objects.get(codename="can_suggest_categories")
61 |
62 | authors = (
63 | Author.objects_accessible.exclude(Q(user_permissions__in=[perm]) | Q(is_novice=True))
64 | .annotate(count=Count("entry", filter=Q(entry__is_draft=False)))
65 | .filter(count__gte=settings.SUGGESTIONS_ENTRY_REQUIREMENT)
66 | )
67 |
68 | for author in authors:
69 | author.user_permissions.add(perm)
70 |
--------------------------------------------------------------------------------
/dictionary/models/managers/topic.py:
--------------------------------------------------------------------------------
1 | from contextlib import suppress
2 |
3 | from django.core.validators import ValidationError
4 | from django.db import models
5 | from django.db.models import Exists, OuterRef
6 | from django.shortcuts import get_object_or_404
7 |
8 | from dictionary.models import Entry
9 | from dictionary.utils import i18n_lower
10 |
11 |
12 | class TopicManager(models.Manager):
13 | class PseudoTopic:
14 | def __init__(self, title, valid=False):
15 | """
16 | :param title: Title of the requested topic.
17 | :param valid: Determines if the topic could be created
18 | using the requested title.
19 | """
20 | self.title = title
21 | self.exists = False
22 | self.valid = valid
23 |
24 | def __str__(self):
25 | return f"<{self.__class__.__name__} {self.title}>"
26 |
27 | def _get_pseudo(self, title):
28 | pseudo = self.PseudoTopic(title)
29 |
30 | try:
31 | self.model(title=title).full_clean()
32 | except ValidationError:
33 | return pseudo
34 |
35 | pseudo.valid = True
36 | return pseudo
37 |
38 | @staticmethod
39 | def _format_title(title):
40 | return i18n_lower(title).strip()
41 |
42 | def get_or_pseudo(self, slug=None, unicode_string=None, entry_id=None):
43 | if unicode_string is not None:
44 | unicode_string = self._format_title(unicode_string)
45 | with suppress(self.model.DoesNotExist):
46 | return self.get(title=unicode_string)
47 | return self._get_pseudo(unicode_string)
48 |
49 | if slug is not None:
50 | slug = self._format_title(slug)
51 | with suppress(self.model.DoesNotExist):
52 | return self.get(slug=slug)
53 | return self._get_pseudo(slug)
54 |
55 | if entry_id is not None:
56 | entry = get_object_or_404(Entry.objects_published, pk=entry_id)
57 | return entry.topic
58 |
59 | raise ValueError("No arguments given.")
60 |
61 | def create_topic(self, title, created_by=None):
62 | topic = self.create(title=title, created_by=created_by)
63 | return topic
64 |
65 |
66 | class TopicManagerPublished(models.Manager):
67 | # Return topics which has published (by authors) entries
68 | def get_queryset(self):
69 | return super().get_queryset().filter(Exists(Entry.objects.filter(topic=OuterRef("pk"))))
70 |
--------------------------------------------------------------------------------
/dictionary/templates/dictionary/reporting/general.html:
--------------------------------------------------------------------------------
1 | {% extends "dictionary/base.html" %}
2 | {% load functions widget_tweaks i18n filters %}
3 | {% block title %}{% trans "contact" context "titleblock" %}{% endblock %}
4 |
5 | {% block content %}
6 | {% trans "contact" context "titleblock" %}
7 | {% trans "matters to consider" %}
8 |
9 | {% blocktrans with faq_url="/faq/" %}please visit frequently asked questions page before submitting reports.{% endblocktrans %}
10 | {% trans "please state your requests and complaints in a comprehensible and concise way." %}
11 | {% trans "we will not notify other authors if you decide to report them, this is done anonymously." %}
12 | {% trans "provide an e-mail address for confirmation and feedback. if you are logged in already, we will use your registered e-mail address." %}
13 | {% trans "if have multiple statements, please consider them all in a report instead of sending them in seperate reports." %}
14 |
15 |
16 |
17 | {% if not user.is_authenticated %}
18 |
19 | {{ form.reporter_email.label|i18n_lower }}
20 | {% render_field form.reporter_email class="form-control" id="reporter_email" %}
21 |
22 | {% else %}
23 |
24 | {% endif %}
25 |
26 |
27 | {{ form.category.label|i18n_lower }}
28 | {% render_field form.category class="form-control" id="report_category" %}
29 |
30 |
31 | {{ form.subject.label|i18n_lower }}
32 | {% render_field form.subject|attr:"autofocus" class="form-control" id="reporter_subject" %}
33 |
34 |
35 | {{ form.content.label|i18n_lower }}
36 | {% render_field form.content rows="5" class="form-control" id="reporter_content" %}
37 |
38 |
39 | {% csrf_token %}
40 | {% trans "send" %}
41 |
42 | {% endblock %}
43 |
--------------------------------------------------------------------------------
/dictionary_graph/topic/action.py:
--------------------------------------------------------------------------------
1 | from django.shortcuts import get_object_or_404
2 | from django.template.defaultfilters import linebreaksbr
3 | from django.utils.translation import gettext as _
4 |
5 | from graphene import ID, Mutation, String
6 |
7 | from dictionary.models import Topic, Wish
8 | from dictionary.templatetags.filters import formatted
9 | from dictionary.utils import smart_lower
10 | from dictionary.utils.validators import validate_user_text
11 | from dictionary_graph.utils import login_required
12 |
13 |
14 | class FollowTopic(Mutation):
15 | class Arguments:
16 | pk = ID()
17 |
18 | feedback = String()
19 |
20 | @staticmethod
21 | @login_required
22 | def mutate(_root, info, pk):
23 | topic = get_object_or_404(Topic, id=pk)
24 | following = info.context.user.following_topics
25 |
26 | if following.filter(pk=pk).exists():
27 | following.remove(topic)
28 | return FollowTopic(feedback=_("you no longer follow this topic"))
29 |
30 | following.add(topic)
31 | return FollowTopic(_("you are now following this topic"))
32 |
33 |
34 | class WishTopic(Mutation):
35 | class Arguments:
36 | title = String()
37 | hint = String()
38 |
39 | feedback = String()
40 | hint = String()
41 |
42 | @staticmethod
43 | @login_required
44 | def mutate(_root, info, title, hint=""):
45 | sender = info.context.user
46 |
47 | if not sender.is_accessible:
48 | raise ValueError(_("sorry, the genie is now busy"))
49 |
50 | topic = Topic.objects.get_or_pseudo(unicode_string=title)
51 | hint = smart_lower(hint).strip()
52 |
53 | if hint:
54 | validate_user_text(hint, exctype=ValueError)
55 |
56 | if not topic.valid or (topic.exists and (topic.is_banned or topic.has_entries)):
57 | raise ValueError(_("we couldn't handle your request. try again later."))
58 |
59 | if not topic.exists:
60 | topic = Topic.objects.create_topic(title=title)
61 | else:
62 | previous_wish = topic.wishes.filter(author=sender)
63 | deleted, _types = previous_wish.delete()
64 |
65 | if deleted:
66 | return WishTopic(feedback=_("your wish has been deleted"))
67 |
68 | Wish.objects.create(topic=topic, author=sender, hint=hint)
69 |
70 | return WishTopic(
71 | feedback=_("your wish is now enlisted. if someone starts a discussion, we will let you know."),
72 | hint=linebreaksbr(formatted(hint)),
73 | )
74 |
--------------------------------------------------------------------------------
/dictionary/templates/dictionary/edit/comment_form.html:
--------------------------------------------------------------------------------
1 | {% extends "dictionary/base.html" %}
2 |
3 | {% load widget_tweaks filters i18n %}
4 |
5 | {% block title %}{{ entry.topic.title }}#{{ entry.pk }} {{ updating|yesno:_(" - updating comment, - commenting") }}{% endblock %}
6 |
7 | {% block content %}
8 |
12 |
13 | {% blocktrans trimmed with name=entry.author.username url=entry.author.get_absolute_url %}
14 | user with nickname {{ name }} wrote:
15 | {% endblocktrans %}
16 |
17 |
18 | {{ entry.content|formatted|linebreaksbr }}
19 |
20 |
25 |
26 | {% if not user.is_novice %}
27 | {% csrf_token %}
28 | {% endif %}
29 |
45 |
46 | {% endblock %}
47 |
--------------------------------------------------------------------------------
/dictionary/models/announcements.py:
--------------------------------------------------------------------------------
1 | from django.db import models
2 | from django.shortcuts import reverse
3 | from django.utils import timezone
4 | from django.utils.translation import gettext_lazy as _
5 |
6 | from uuslug import uuslug
7 |
8 | from dictionary.templatetags.filters import entrydate
9 |
10 |
11 | class Announcement(models.Model):
12 | title = models.CharField(max_length=254, verbose_name=_("Title"))
13 | content = models.TextField(verbose_name=_("Content"))
14 | slug = models.SlugField(editable=False)
15 |
16 | discussion = models.ForeignKey(
17 | "Topic",
18 | null=True,
19 | blank=True,
20 | on_delete=models.SET_NULL,
21 | verbose_name=_("Discussion topic"),
22 | help_text=_("Optional. The topic where the users will be discussing this announcement."),
23 | )
24 |
25 | html_only = models.BooleanField(
26 | default=False,
27 | verbose_name=_("Allow HTML"),
28 | help_text=_("Check this to only use HTML, otherwise you can use entry formatting options."),
29 | )
30 |
31 | notify = models.BooleanField(
32 | default=False,
33 | verbose_name=_("Notify users"),
34 | help_text=_("When checked, users will get a notification when the announcement gets released."),
35 | )
36 |
37 | date_edited = models.DateTimeField(null=True, editable=False)
38 | date_created = models.DateTimeField(
39 | db_index=True,
40 | verbose_name=_("Publication date"),
41 | help_text=_("You can set future dates for the publication date."),
42 | )
43 |
44 | class Meta:
45 | verbose_name = _("announcement")
46 | verbose_name_plural = _("announcements")
47 |
48 | def __str__(self):
49 | return f"{self.title} - {entrydate(timezone.localtime(self.date_created), None)}"
50 |
51 | def save(self, *args, **kwargs):
52 | created = self.pk is None
53 |
54 | if created:
55 | self.slug = uuslug(self.title, instance=self)
56 | elif self.date_created < timezone.now():
57 | # Pre-save content check for published announcement
58 | previous = Announcement.objects.get(pk=self.pk)
59 |
60 | if previous.content != self.content or previous.title != self.title:
61 | self.date_edited = timezone.now()
62 |
63 | super().save(*args, **kwargs)
64 |
65 | def get_absolute_url(self):
66 | pub = timezone.localtime(self.date_created)
67 | return reverse(
68 | "announcements-detail",
69 | kwargs={"year": pub.year, "month": pub.month, "day": pub.day, "slug": self.slug},
70 | )
71 |
--------------------------------------------------------------------------------
/dictionary/urls/auth.py:
--------------------------------------------------------------------------------
1 | from django.contrib.auth.views import (
2 | PasswordResetCompleteView,
3 | PasswordResetConfirmView,
4 | PasswordResetDoneView,
5 | PasswordResetView,
6 | )
7 | from django.urls import path
8 |
9 | from dictionary.conf import settings
10 | from dictionary.views.auth import (
11 | ChangeEmail,
12 | ChangePassword,
13 | ConfirmEmail,
14 | CreateBackup,
15 | DownloadBackup,
16 | Login,
17 | Logout,
18 | ResendEmailConfirmation,
19 | SignUp,
20 | TerminateAccount,
21 | )
22 | from dictionary.views.reporting import VerifyReport
23 |
24 | urlpatterns_password_reset = [
25 | path(
26 | "password/",
27 | PasswordResetView.as_view(
28 | template_name="dictionary/registration/password_reset/form.html",
29 | html_email_template_name="dictionary/registration/password_reset/email_template.html",
30 | from_email=settings.FROM_EMAIL,
31 | ),
32 | name="password_reset",
33 | ),
34 | path(
35 | "password/done/",
36 | PasswordResetDoneView.as_view(template_name="dictionary/registration/password_reset/done.html"),
37 | name="password_reset_done",
38 | ),
39 | path(
40 | "password/confirm///",
41 | PasswordResetConfirmView.as_view(template_name="dictionary/registration/password_reset/confirm.html"),
42 | name="password_reset_confirm",
43 | ),
44 | path(
45 | "password/complete/",
46 | PasswordResetCompleteView.as_view(template_name="dictionary/registration/password_reset/complete.html"),
47 | name="password_reset_complete",
48 | ),
49 | ]
50 |
51 | urlpatterns_auth = urlpatterns_password_reset + [
52 | path("login/", Login.as_view(), name="login"),
53 | path("register/", SignUp.as_view(), name="register"),
54 | path("logout/", Logout.as_view(next_page="/"), name="logout"),
55 | path("email/confirm//", ConfirmEmail.as_view(), name="confirm-email"),
56 | path("email/resend/", ResendEmailConfirmation.as_view(), name="resend-email"),
57 | path("settings/password/", ChangePassword.as_view(), name="user_preferences_password"),
58 | path("settings/email/", ChangeEmail.as_view(), name="user_preferences_email"),
59 | path("settings/account-termination/", TerminateAccount.as_view(), name="user_preferences_terminate"),
60 | path("settings/backup/", CreateBackup.as_view(), name="user_preferences_backup"),
61 | path("settings/backup/download/", DownloadBackup.as_view(), name="user_preferences_backup_download"),
62 | path("contact/confirm//", VerifyReport.as_view(), name="verify-report"),
63 | ]
64 |
--------------------------------------------------------------------------------
/dictionary/utils/decorators.py:
--------------------------------------------------------------------------------
1 | from functools import wraps
2 |
3 | from django.core.cache import cache
4 |
5 | # General decorators
6 |
7 |
8 | def cached_context(initial_func=None, *, timeout=None, vary_on_user=False, prefix="default"):
9 | """
10 | Decorator to cache functions using django's low-level cache api. Arguments
11 | are not taken into consideration while caching, so values of func(a, b) and
12 | func(b, d) will be the same (the value of first called).
13 |
14 | :param initial_func: (decorator thingy, passed when used with parameters)
15 | :param timeout: Set the cache timeout, None to cache indefinitely.
16 | :param prefix: Cache keys are set using the name of the function. Set a
17 | unique prefix to avoid clashes between functions/methods that with same name.
18 | :param vary_on_user: Set True to cache per user (anonymous users will have
19 | the same value). The wrapped function needs to have either "request" as
20 | argument or "user" as keyword argument.
21 | """
22 |
23 | def decorator(func):
24 | @wraps(func)
25 | def wrapper(*args, **kwargs):
26 | user_prefix = ""
27 |
28 | if vary_on_user:
29 | request = kwargs.get("request")
30 |
31 | if request is not None:
32 | user = request.user
33 | else:
34 | user = kwargs.get("user")
35 |
36 | user_prefix = "_anonymous" if not user.is_authenticated else f"_usr{user.pk}"
37 |
38 | func_name = ""
39 |
40 | if hasattr(func, "__name__"):
41 | func_name = func.__name__
42 | elif prefix == "default":
43 | raise ValueError("Usage with non-wrapped decorators require an unique prefix.")
44 |
45 | key = f"{prefix}_context__{func_name}{user_prefix}"
46 | cached_value = cache.get(key)
47 |
48 | if cached_value is not None:
49 | return cached_value
50 |
51 | calculated_value = func(*args, **kwargs)
52 | cache.set(key, calculated_value, timeout)
53 | return calculated_value
54 |
55 | return wrapper
56 |
57 | if initial_func:
58 | return decorator(initial_func)
59 | return decorator
60 |
61 |
62 | def for_public_methods(decorator):
63 | """Decorate each 'public' method of this class with given decorator."""
64 |
65 | def decorate(cls):
66 | for attr in dir(cls):
67 | if not attr.startswith("_") and callable(getattr(cls, attr)):
68 | setattr(cls, attr, decorator(getattr(cls, attr)))
69 | return cls
70 |
71 | return decorate
72 |
--------------------------------------------------------------------------------
/dictionary/templates/dictionary/conversation/inbox.html:
--------------------------------------------------------------------------------
1 | {% extends "dictionary/conversation/inbox_base.html" %}
2 | {% load humanize i18n %}
3 |
4 | {% block title %}{% trans "messages" context "titleblock" %}{% endblock %}
5 |
6 | {% block titlemeta %}
7 | {% if request.GET.search_term.strip %}
8 | [{% trans "search keywords" %}: {{ request.GET.search_term.strip }} ]
9 | {% endif %}
10 | {% endblock %}
11 |
12 | {% block innercontent %}
13 | {% if conversations %}
14 |
44 | {% else %}
45 | {% trans "sorry, but couldn't find anything 🤷" %}
46 | {% endif %}
47 | {% endblock %}
48 |
--------------------------------------------------------------------------------
/dictionary/templates/dictionary/edit/entry_update.html:
--------------------------------------------------------------------------------
1 | {% extends "dictionary/base.html" %}
2 |
3 | {% load filters widget_tweaks i18n %}
4 | {% block title %}{% trans "edit" context "titleblock" %}{% endblock %}
5 | {% block content %}
6 |
10 |
11 |
12 | {% if entry.is_draft %}
13 |
14 | {% trans "preview" %}
15 | {{ entry.content|formatted|linebreaksbr }}
16 |
17 | {% endif %}
18 |
19 | {% if not user.is_novice %}
20 | {% csrf_token %}
21 | {% endif %}
22 |
23 |
46 |
47 | {% endblock %}
48 |
--------------------------------------------------------------------------------
/dictionary/static/dictionary/js/image.js:
--------------------------------------------------------------------------------
1 | /* global gettext */
2 |
3 | import { Handle, Handler, template, gqlc, notify } from "./utils"
4 |
5 | const showImageErrorMessage = () => {
6 | notify(gettext("image could not be displayed. it might have been deleted."), "error", 2200)
7 | }
8 |
9 | Handle(document, "click", event => {
10 | const self = event.target
11 | if (self.matches(".entry a[data-img], .text-formatted a[data-img]")) {
12 | if (self.hasAttribute("data-broken")) {
13 | showImageErrorMessage()
14 | return
15 | }
16 |
17 | if (!self.hasAttribute("data-loaded")) {
18 | const p = self.parentNode
19 | p.style.maxHeight = "none" // Click "read more" button.
20 | const readMore = p.querySelector(".read_more")
21 |
22 | if (readMore) {
23 | readMore.style.display = "none"
24 | }
25 |
26 | const url = self.getAttribute("data-img")
27 | const image = template(` `)
28 | const expander = template(` `)
29 |
30 | image.onerror = () => {
31 | showImageErrorMessage()
32 | image.style.display = "none"
33 | expander.style.display = "none"
34 | self.setAttribute("data-broken", "true")
35 | }
36 |
37 | self.after(expander)
38 | expander.after(image)
39 | self.setAttribute("aria-expanded", "true")
40 | } else {
41 | self.nextElementSibling.classList.toggle("d-none")
42 | self.nextElementSibling.nextElementSibling.classList.toggle("d-none")
43 |
44 | if (self.getAttribute("aria-expanded") === "true") {
45 | self.setAttribute("aria-expanded", "false")
46 | } else {
47 | self.setAttribute("aria-expanded", "true")
48 | }
49 | }
50 | self.setAttribute("data-loaded", "true")
51 | }
52 | })
53 |
54 | function deleteImage (slug) {
55 | return gqlc({
56 | query: "mutation($slug:String!){image{delete(slug:$slug){feedback}}}",
57 | variables: { slug }
58 | })
59 | }
60 |
61 | Handler("a[role=button].delete-image", "click", function () {
62 | if (confirm(gettext("Are you sure?"))) {
63 | const img = this.closest(".image-detail")
64 | deleteImage(img.getAttribute("data-slug"))
65 | img.remove()
66 | }
67 | })
68 |
69 | export { deleteImage }
70 |
--------------------------------------------------------------------------------
/dictionary/forms/edit.py:
--------------------------------------------------------------------------------
1 | from django import forms
2 | from django.contrib.auth.forms import UserChangeForm
3 | from django.forms.widgets import SelectDateWidget
4 | from django.utils.translation import gettext, gettext_lazy as _
5 |
6 | from dictionary.conf import settings
7 | from dictionary.models import Author, Entry, Memento, Message
8 |
9 |
10 | class PreferencesForm(UserChangeForm):
11 | password = None
12 |
13 | gender = forms.ChoiceField(choices=Author.Gender.choices, label=_("gender"))
14 | birth_date = forms.DateField(widget=SelectDateWidget(years=settings.BIRTH_YEAR_RANGE), label=_("birth date"))
15 | entries_per_page = forms.ChoiceField(choices=Author.EntryCount.choices, label=_("entries per page"))
16 | topics_per_page = forms.ChoiceField(choices=Author.TopicCount.choices, label=_("topics per page"))
17 | message_preference = forms.ChoiceField(choices=Author.MessagePref.choices, label=_("message preference"))
18 | allow_receipts = forms.BooleanField(required=False, label=_("show read receipts"))
19 | allow_uncategorized = forms.BooleanField(required=False, label=_("allow uncategorized topics in today"))
20 | allow_site_announcements = forms.BooleanField(required=False, label=_("include site announcements in my activity"))
21 |
22 | class Meta:
23 | model = Author
24 | fields = (
25 | "gender",
26 | "birth_date",
27 | "entries_per_page",
28 | "topics_per_page",
29 | "message_preference",
30 | "allow_receipts",
31 | "allow_uncategorized",
32 | "allow_site_announcements",
33 | )
34 |
35 |
36 | class EntryForm(forms.ModelForm):
37 | class Meta:
38 | model = Entry
39 | fields = ("content",)
40 | error_messages = {"content": {"required": _("my dear, just write your entry, how hard could it be?")}}
41 |
42 |
43 | class SendMessageForm(forms.ModelForm):
44 | # Used in conversation (previously created)
45 | class Meta:
46 | model = Message
47 | fields = ("body",)
48 | labels = {"body": _("message")}
49 |
50 | error_messages = {"body": {"required": _("can't really understand you")}}
51 |
52 | def clean(self):
53 | msg = self.cleaned_data.get("body", "")
54 |
55 | if len(msg) < 3:
56 | raise forms.ValidationError(gettext("that message is just too short"))
57 |
58 | super().clean()
59 |
60 |
61 | class StandaloneMessageForm(SendMessageForm):
62 | # Used to create new conversations (in messages list view)
63 | recipient = forms.CharField(label=_("to who"))
64 |
65 |
66 | class MementoForm(forms.ModelForm):
67 | class Meta:
68 | model = Memento
69 | fields = ("body",)
70 |
--------------------------------------------------------------------------------
/dictionary/templates/admin/actions/suspend_user.html:
--------------------------------------------------------------------------------
1 | {% extends "admin/base_site.html" %}
2 | {% load i18n %}
3 | {% block breadcrumbs %}
4 |
14 | {% endblock %}
15 |
16 | {% block content %}
17 | {% trans "You have selected these users to suspend:" %}
18 | {% load humanize %}
19 |
20 | {% for source in sources %}
21 |
22 |
23 | {{ source.username }}
24 |
25 | {% if source.last_entry_date %}
26 | ({% trans "last entry date:" %}{{ source.last_entry_date|naturaltime }})
27 | {% endif %}
28 |
29 | {% endfor %}
30 |
31 |
32 |
33 | {% trans "Time interval for suspension:" %}
34 |
35 |
36 |
37 | {% trans "hour(s)" %}
38 | {% trans "day(s)" %}
39 | {% trans "week(s)" %}
40 | {% trans "month(s)" %}
41 | {% trans "year(s)" %}
42 |
43 |
44 |
45 |
46 | {% trans "Information messages to be shown for users:" %}
47 |
51 | {% csrf_token %}
52 |
53 |
54 |
55 | {% endblock %}
56 |
--------------------------------------------------------------------------------
/dictionary/templates/dictionary/registration/password_reset/confirm.html:
--------------------------------------------------------------------------------
1 | {% extends "dictionary/base.html" %}
2 | {% load widget_tweaks i18n %}
3 | {% block title %}{% trans "password reset" context "titleblock" %}{% endblock %}
4 | {% block content %}
5 | {% trans "password reset" context "titleblock" %}
6 |
7 | {% if form %}
8 |
9 | {% csrf_token %}
10 |
11 |
43 |
44 | {% trans "hopefully i won't forget it again" %}
45 |
46 | {% else %}
47 | {% url "password_reset" as password_reset_url %}
48 |
49 | {% blocktrans trimmed %}
50 | hello dear, we couldn't really understand you. if you want to reset your password visit
51 |
this page .
52 | {% endblocktrans %}
53 |
54 | {% endif %}
55 | {% endblock %}
56 |
--------------------------------------------------------------------------------
/dictionary/templates/dictionary/user/preferences/base.html:
--------------------------------------------------------------------------------
1 | {% extends "dictionary/base.html" %}
2 | {% load i18n %}
3 | {% block title %}{% trans "settings" context "titleblock" %}{% endblock %}
4 | {% block content %}
5 |
6 |
{% trans "settings" context "titleblock" %}
7 |
8 |
9 |
10 | {% trans "Settings menu" %}
11 |
12 |
13 |
14 |
15 | {% url 'user_preferences' as preferences %}
16 | {% url 'user_preferences_password' as password %}
17 | {% url 'user_preferences_email' as email %}
18 | {% url 'user_preferences_terminate' as terminate %}
19 | {% url 'user_preferences_backup' as backup %}
20 |
21 |
28 |
29 |
30 |
31 |
48 |
49 | {% block content_preferences %}{% endblock %}
50 | {% endblock %}
51 |
--------------------------------------------------------------------------------
/docker/prod/nginx/nginx.conf:
--------------------------------------------------------------------------------
1 | user nginx;
2 | pid /var/run/nginx.pid;
3 | worker_processes auto;
4 | worker_rlimit_nofile 65535;
5 |
6 | # Load modules
7 | include /etc/nginx/modules-enabled/*.conf;
8 |
9 | events {
10 | multi_accept on;
11 | worker_connections 65535;
12 | }
13 |
14 | http {
15 | charset utf-8;
16 | sendfile on;
17 | tcp_nopush on;
18 | tcp_nodelay on;
19 | server_tokens off;
20 | log_not_found off;
21 | types_hash_max_size 2048;
22 | types_hash_bucket_size 64;
23 | client_max_body_size 16M;
24 |
25 | # MIME
26 | include mime.types;
27 | default_type application/octet-stream;
28 |
29 | # Logging
30 | access_log off;
31 | error_log /dev/null;
32 |
33 | # Connection header for WebSocket reverse proxy
34 | map $http_upgrade $connection_upgrade {
35 | default upgrade;
36 | "" close;
37 | }
38 |
39 | map $remote_addr $proxy_forwarded_elem {
40 |
41 | # IPv4 addresses can be sent as-is
42 | ~^[0-9.]+$ "for=$remote_addr";
43 |
44 | # IPv6 addresses need to be bracketed and quoted
45 | ~^[0-9A-Fa-f:.]+$ "for=\"[$remote_addr]\"";
46 |
47 | # Unix domain socket names cannot be represented in RFC 7239 syntax
48 | default "for=unknown";
49 | }
50 |
51 | map $http_forwarded $proxy_add_forwarded {
52 |
53 | # If the incoming Forwarded header is syntactically valid, append to it
54 | "~^(,[ \\t]*)*([!#$%&'*+.^_`|~0-9A-Za-z-]+=([!#$%&'*+.^_`|~0-9A-Za-z-]+|\"([\\t \\x21\\x23-\\x5B\\x5D-\\x7E\\x80-\\xFF]|\\\\[\\t \\x21-\\x7E\\x80-\\xFF])*\"))?(;([!#$%&'*+.^_`|~0-9A-Za-z-]+=([!#$%&'*+.^_`|~0-9A-Za-z-]+|\"([\\t \\x21\\x23-\\x5B\\x5D-\\x7E\\x80-\\xFF]|\\\\[\\t \\x21-\\x7E\\x80-\\xFF])*\"))?)*([ \\t]*,([ \\t]*([!#$%&'*+.^_`|~0-9A-Za-z-]+=([!#$%&'*+.^_`|~0-9A-Za-z-]+|\"([\\t \\x21\\x23-\\x5B\\x5D-\\x7E\\x80-\\xFF]|\\\\[\\t \\x21-\\x7E\\x80-\\xFF])*\"))?(;([!#$%&'*+.^_`|~0-9A-Za-z-]+=([!#$%&'*+.^_`|~0-9A-Za-z-]+|\"([\\t \\x21\\x23-\\x5B\\x5D-\\x7E\\x80-\\xFF]|\\\\[\\t \\x21-\\x7E\\x80-\\xFF])*\"))?)*)?)*$" "$http_forwarded, $proxy_forwarded_elem";
55 |
56 | # Otherwise, replace it
57 | default "$proxy_forwarded_elem";
58 | }
59 |
60 | server {
61 | listen 80;
62 | listen [::]:80;
63 |
64 | listen 443 ssl;
65 | listen [::]:443 ssl;
66 | include include/ssl.conf;
67 |
68 | server_name _ default;
69 | return 444;
70 | }
71 |
72 | # Load configs
73 | include /etc/nginx/conf.d/*.conf;
74 | include /etc/nginx/sites-enabled/*;
75 | }
76 |
--------------------------------------------------------------------------------
/dictionary/static/dictionary/js/dialog.js:
--------------------------------------------------------------------------------
1 | /* global gettext */
2 |
3 | import { Handle, many, one, gqlc, isValidText, notify } from "./utils"
4 | import { userAction } from "./user"
5 |
6 | function showBlockDialog (recipient, redirect = true, returnTo = null) {
7 | const button = one("#block_user")
8 | button.setAttribute("data-username", recipient)
9 | button.setAttribute("data-re", redirect)
10 | one("#username-holder").textContent = recipient
11 |
12 | const modal = one("#blockUserModal")
13 | modal._modalInstance.show(returnTo)
14 | }
15 |
16 | Handle("#block_user", "click", function () {
17 | // Modal button click event
18 | const targetUser = this.getAttribute("data-username")
19 | const re = this.getAttribute("data-re") === "true"
20 | if (!re) {
21 | many(".entry-full").forEach(entry => {
22 | if (entry.querySelector(".meta .username").textContent === targetUser) {
23 | entry.remove()
24 | }
25 | })
26 | }
27 | userAction("block", targetUser, null, re)
28 | })
29 |
30 | function showMessageDialog (recipient, extraContent, returnTo = null) {
31 | const msgModal = one("#sendMessageModal")
32 | one("#sendMessageModal span.username").textContent = recipient
33 |
34 | if (extraContent) {
35 | one("#sendMessageModal textarea#message_body").value = extraContent
36 | }
37 |
38 | msgModal.setAttribute("data-for", recipient)
39 | msgModal._modalInstance.show(returnTo)
40 | }
41 |
42 | function composeMessage (recipient, body) {
43 | const variables = { recipient, body }
44 | const query = `mutation compose($body:String!,$recipient:String!){message{compose(body:$body,recipient:$recipient){feedback}}}`
45 | return gqlc({ query, variables }).then(function (response) {
46 | notify(response.data.message.compose.feedback)
47 | })
48 | }
49 |
50 | Handle("#send_message_btn", "click", function () {
51 | const textarea = one("#sendMessageModal textarea")
52 | const msgModal = one("#sendMessageModal")
53 | const body = textarea.value
54 |
55 | if (body.length < 3) {
56 | // not strictly needed but written so as to reduce api calls.
57 | notify(gettext("if only you could write down something"), "error")
58 | return
59 | }
60 |
61 | if (!isValidText(body)) {
62 | notify(gettext("this content includes forbidden characters."), "error")
63 | return
64 | }
65 |
66 | this.disabled = true
67 |
68 | composeMessage(msgModal.getAttribute("data-for"), body).then(() => {
69 | msgModal._modalInstance.hide()
70 | textarea.value = ""
71 | }).finally(() => {
72 | this.disabled = false
73 | })
74 | })
75 |
76 | export { showBlockDialog, showMessageDialog }
77 |
--------------------------------------------------------------------------------
/dictionary/models/managers/author.py:
--------------------------------------------------------------------------------
1 | import logging
2 |
3 | from django.contrib.auth.models import UserManager
4 | from django.db import models
5 | from django.db.models import BooleanField, Case, Q, When
6 | from django.utils import timezone
7 |
8 | from dictionary.utils import get_generic_privateuser, time_threshold
9 |
10 | logger = logging.getLogger(__name__)
11 |
12 |
13 | class AuthorManagerAccessible(UserManager):
14 | def get_queryset(self):
15 | return (
16 | super()
17 | .get_queryset()
18 | .exclude(
19 | Q(is_frozen=True) | Q(is_private=True) | Q(is_active=False) | Q(suspended_until__gt=timezone.now())
20 | )
21 | )
22 |
23 |
24 | class InNoviceList(AuthorManagerAccessible):
25 | def get_queryset(self):
26 | return super().get_queryset().filter(is_novice=True, application_status="PN")
27 |
28 | def get_ordered(self, limit=None):
29 | """Return all users in novice list, ordered."""
30 | qs = self.annotate_activity(self.filter(last_activity__isnull=False)).order_by(
31 | "-queue_priority", "-is_active_today", "application_date"
32 | )
33 | if limit is not None:
34 | return qs[:limit]
35 | return qs
36 |
37 | @staticmethod
38 | def annotate_activity(queryset):
39 | return queryset.annotate(
40 | is_active_today=Case(
41 | When(Q(last_activity__gte=time_threshold(hours=24)), then=True),
42 | default=False,
43 | output_field=BooleanField(),
44 | )
45 | )
46 |
47 |
48 | class AccountTerminationQueueManager(models.Manager):
49 | _private_user = None
50 |
51 | def get_terminated(self):
52 | return self.exclude(state="FZ").filter(termination_date__lt=timezone.now())
53 |
54 | @staticmethod
55 | def terminate_no_trace(user):
56 | logger.info("User account terminated: %s<->%d", user.username, user.pk)
57 | user.delete()
58 |
59 | def terminate_legacy(self, user):
60 | if not user.is_novice:
61 | # Migrate entries before deleting the user completely
62 | user.entry_set(manager="objects_published").all().update(author=self._private_user)
63 | logger.info("User entries migrated: %s<->%d", user.username, user.pk)
64 |
65 | self.terminate_no_trace(user)
66 |
67 | def commit_terminations(self):
68 | self._private_user = get_generic_privateuser()
69 |
70 | for termination in self.get_terminated().select_related("author"):
71 | if termination.state == "NT":
72 | self.terminate_no_trace(termination.author)
73 | elif termination.state == "LE":
74 | self.terminate_legacy(termination.author)
75 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 |
2 | # Created by https://www.gitignore.io/api/django
3 | # Edit at https://www.gitignore.io/?templates=django
4 |
5 | ### Django ###
6 | *.log
7 | *.pot
8 | *.pyc
9 | __pycache__/
10 | local_settings.py
11 | db.sqlite3
12 | db.sqlite3-journal
13 | media
14 |
15 | # If your build process includes running collectstatic, then you probably don't need or want to include staticfiles/
16 | # in your Git repository. Update and uncomment the following line accordingly.
17 | # /staticfiles/
18 |
19 | ### Django.Python Stack ###
20 | # Byte-compiled / optimized / DLL files
21 | *.py[cod]
22 | *$py.class
23 |
24 | # C extensions
25 | *.so
26 |
27 | # Distribution / packaging
28 | .Python
29 | build/
30 | develop-eggs/
31 | downloads/
32 | eggs/
33 | .eggs/
34 | lib64/
35 | parts/
36 | sdist/
37 | var/
38 | wheels/
39 | pip-wheel-metadata/
40 | share/python-wheels/
41 | *.egg-info/
42 | .installed.cfg
43 | *.egg
44 | MANIFEST
45 |
46 | # PyInstaller
47 | # Usually these files are written by a python script from a template
48 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
49 | *.manifest
50 | *.spec
51 |
52 | # Installer logs
53 | pip-log.txt
54 | pip-delete-this-directory.txt
55 |
56 | # Unit test / coverage reports
57 | htmlcov/
58 | .tox/
59 | .nox/
60 | .coverage
61 | .coverage.*
62 | .cache
63 | nosetests.xml
64 | coverage.xml
65 | *.cover
66 | .hypothesis/
67 | .pytest_cache/
68 |
69 |
70 | # Scrapy stuff:
71 | .scrapy
72 |
73 | # Sphinx documentation
74 | docs/_build/
75 |
76 | # PyBuilder
77 | target/
78 |
79 | # pyenv
80 | .python-version
81 |
82 | # pipenv
83 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
84 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
85 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
86 | # install all needed dependencies.
87 | #Pipfile.lock
88 |
89 | # celery beat schedule file
90 | celerybeat-schedule
91 |
92 | # SageMath parsed files
93 | *.sage.py
94 |
95 | # Spyder project settings
96 | .spyderproject
97 | .spyproject
98 |
99 | # Rope project settings
100 | .ropeproject
101 |
102 | # Mr Developer
103 | .mr.developer.cfg
104 | .project
105 | .pydevproject
106 |
107 | # mkdocs documentation
108 | /site
109 |
110 | # mypy
111 | .mypy_cache/
112 | .dmypy.json
113 | dmypy.json
114 |
115 | # Pyre type checker
116 | .pyre/
117 |
118 | # PyCharm
119 |
120 | .idea
121 |
122 | # node stuff
123 | node_modules
124 | package-lock.json
125 | .parcel-cache
126 |
127 | # datadump
128 | datadump.json
129 |
130 | #virtual environment
131 |
132 | venv/
133 | .venv/
134 | venv-linux/
135 |
136 | # End of https://www.gitignore.io/api/django
137 |
--------------------------------------------------------------------------------
/dictionary/static/dictionary/js/lib/modal.js:
--------------------------------------------------------------------------------
1 | import { many } from "../utils"
2 |
3 | class Modal {
4 | constructor (modal) {
5 | this.modal = modal
6 | this.showing = false
7 | this.lead = modal.querySelector(".lead")
8 |
9 | modal.addEventListener("click", event => {
10 | if (!event.target.closest(".modal-content") || event.target.closest("[data-dismiss=modal]")) {
11 | this.hide()
12 | }
13 | })
14 |
15 | // https://uxdesign.cc/how-to-trap-focus-inside-modal-to-make-it-ada-compliant-6a50f9a70700
16 | const focusableElements = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
17 | const focusableContent = modal.querySelectorAll(focusableElements)
18 | const firstFocusableElement = focusableContent[0]
19 | const lastFocusableElement = focusableContent[focusableContent.length - 1]
20 |
21 | modal.addEventListener("keydown", event => {
22 | if (event.key !== "Tab") {
23 | return
24 | }
25 |
26 | if (event.shiftKey) {
27 | if (document.activeElement === firstFocusableElement) {
28 | lastFocusableElement.focus()
29 | event.preventDefault()
30 | }
31 | } else {
32 | if (document.activeElement === lastFocusableElement) {
33 | firstFocusableElement.focus()
34 | event.preventDefault()
35 | }
36 | }
37 | })
38 | }
39 |
40 | show (returnTo) {
41 | this.returnTo = returnTo
42 | this.showing = true
43 | this.modal.removeAttribute("aria-hidden")
44 | this.modal.classList.add("showing")
45 | this.modal.style.display = "block"
46 | setTimeout(() => {
47 | this.modal.classList.add("show")
48 | this.lead.focus()
49 | }, 0)
50 | }
51 |
52 | hide () {
53 | if (!this.showing) {
54 | return false
55 | }
56 |
57 | const _modal = this.modal
58 | _modal.classList.remove("show")
59 |
60 | new Promise(resolve => {
61 | _modal.addEventListener("transitionend", function _transitionend (event) {
62 | if (event.target === _modal) {
63 | resolve(_transitionend)
64 | }
65 | })
66 | }).then(_transitionend => {
67 | _modal.removeEventListener("transitionend", _transitionend)
68 | _modal.style.display = "none"
69 | _modal.classList.remove("showing")
70 | _modal.setAttribute("aria-hidden", "true")
71 | this.returnTo && this.returnTo.focus()
72 | })
73 | }
74 | }
75 |
76 | many(".modal[role=dialog]").forEach(modal => {
77 | modal._modalInstance = new Modal(modal)
78 | })
79 |
--------------------------------------------------------------------------------
/dictionary_graph/utils.py:
--------------------------------------------------------------------------------
1 | from functools import wraps
2 |
3 | from django.core.exceptions import PermissionDenied
4 | from django.utils.translation import gettext as _
5 |
6 | from dictionary.conf import settings
7 |
8 |
9 | def login_required(func):
10 | """
11 | Utility decorator to check if the user logged in (mutations & resolvers).
12 | """
13 |
14 | @wraps(func)
15 | def decorator(_root, info, *args, **kwargs):
16 | if not info.context.user.is_authenticated:
17 | raise PermissionDenied(_("actually, you may benefit from this feature by logging in."))
18 | return func(_root, info, *args, **kwargs)
19 |
20 | return decorator
21 |
22 |
23 | class VoteStorage:
24 | """
25 | A mocking object to mock the m2m fields (upvoted_entries, downvoted_entries)
26 | of Author, for anonymous users.
27 |
28 | Anonymous users can vote, in order to hinder duplicate votes, session is
29 | used; though it is not the best way to handle this, I think it's better than
30 | storing all the IP addresses of the guest users as acquiring an IP address
31 | is a nuance; it depends on the server and it can also be manipulated by keen
32 | hackers. It's just better to stick to this way instead of making things
33 | complicated as there is no way to make this work 100% intended.
34 | """
35 |
36 | def __init__(self, request, name, rate):
37 | self.request = request
38 | self.name = name
39 | self.items = request.session.get(name, [])
40 | self.rate = rate
41 |
42 | def add(self, instance):
43 | self.items.append(instance.pk)
44 | self.request.session[self.name] = self.items
45 | instance.update_vote(self.rate)
46 |
47 | def remove(self, instance):
48 | self.items.remove(instance.pk)
49 | self.request.session[self.name] = self.items
50 | instance.update_vote(self.rate * -1)
51 |
52 | def filter(self, pk):
53 | return self._Filter(pk, self.items)
54 |
55 | class _Filter:
56 | def __init__(self, pk, items):
57 | self.pk = pk
58 | self.items = items
59 |
60 | def exists(self):
61 | return int(self.pk) in self.items
62 |
63 |
64 | class AnonymousUserStorage:
65 | def __init__(self, request):
66 | self.request = request
67 |
68 | @property
69 | def upvoted_entries(self):
70 | return VoteStorage(self.request, name="upvoted_entries", rate=settings.VOTE_RATES["anonymous"])
71 |
72 | @property
73 | def downvoted_entries(self):
74 | return VoteStorage(self.request, name="downvoted_entries", rate=settings.VOTE_RATES["anonymous"] * -1)
75 |
76 | @property
77 | def is_karma_eligible(self):
78 | return False
79 |
80 | def has_exceeded_vote_limit(self, **kwargs):
81 | # 14 = Max allowed votes - 1
82 | return len(self.upvoted_entries.items) + len(self.downvoted_entries.items) > 14, None
83 |
--------------------------------------------------------------------------------
/dictionary/templates/admin/actions/topic_move.html:
--------------------------------------------------------------------------------
1 | {% extends "admin/base_site.html" %}
2 | {% load i18n %}
3 | {% block breadcrumbs %}
4 |
14 | {% endblock %}
15 |
16 | {% block content %}
17 | {% trans "You have selected these topics to move:" %}
18 |
19 |
20 | {% for source in sources %}
21 |
22 |
23 | {{ source.title }}
24 |
25 | ({% trans "entry count:" %} {{ source.entry_count }})
26 |
27 | {% endfor %}
28 |
29 |
30 |
31 |
32 |
33 |
34 | {% trans "Target topic:" %}
35 |
36 |
37 |
38 |
39 |
{% trans "Give a date interval for entries (optional):" %}
40 |
41 | {% trans "from date:" %}
42 | {# Translators: Day should come first. It should be a parsable date. #}
43 |
44 |
45 |
46 |
47 | {% trans "to date:" %}
48 |
49 |
50 |
51 |
52 |
53 |
54 | {% trans "Leave a reference to the new topic after moving" %}
55 |
56 |
57 |
58 | {% csrf_token %}
59 |
60 |
61 |
62 |
63 | {% endblock %}
64 |
--------------------------------------------------------------------------------