├── dictionary ├── __init__.py ├── forms │ ├── __init__.py │ └── edit.py ├── tests │ └── __init__.py ├── views │ ├── __init__.py │ └── announcements.py ├── backends │ ├── __init__.py │ └── sessions │ │ ├── __init__.py │ │ ├── cached_db.py │ │ ├── utils.py │ │ └── db.py ├── middleware │ ├── __init__.py │ ├── csrf.py │ ├── users.py │ └── frontend.py ├── migrations │ ├── __init__.py │ └── 0002_author_unique_author_lower_email.py ├── admin │ ├── views │ │ ├── __init__.py │ │ └── sites.py │ ├── badge.py │ ├── images.py │ ├── sites.py │ ├── category.py │ ├── flatpages.py │ ├── __init__.py │ ├── announcements.py │ ├── general_report.py │ └── entry.py ├── models │ ├── managers │ │ ├── __init__.py │ │ ├── category.py │ │ ├── entry.py │ │ ├── messaging.py │ │ ├── topic.py │ │ └── author.py │ ├── __init__.py │ ├── m2m.py │ ├── flatpages.py │ ├── reporting.py │ ├── images.py │ └── announcements.py ├── templatetags │ └── __init__.py ├── templates │ ├── 403_csrf.html │ ├── dictionary │ │ ├── announcements │ │ │ ├── index.html │ │ │ ├── detail.html │ │ │ ├── month.html │ │ │ ├── post.html │ │ │ └── base.html │ │ ├── includes │ │ │ ├── header_link.html │ │ │ ├── devinfo.html │ │ │ ├── send_message_modal.html │ │ │ ├── paginaton.html │ │ │ ├── profile_entry_pinned.html │ │ │ ├── forms │ │ │ │ └── wish.html │ │ │ ├── block_user_modal.html │ │ │ └── editor_buttons.html │ │ ├── registration │ │ │ ├── email │ │ │ │ ├── confirmation_email_template.html │ │ │ │ ├── confirmation_result.html │ │ │ │ └── resend_form.html │ │ │ ├── password_reset │ │ │ │ ├── email_template.html │ │ │ │ ├── complete.html │ │ │ │ ├── done.html │ │ │ │ ├── form.html │ │ │ │ └── confirm.html │ │ │ └── login.html │ │ ├── index.html │ │ ├── user │ │ │ └── preferences │ │ │ │ ├── backup.html │ │ │ │ ├── password.html │ │ │ │ ├── email.html │ │ │ │ └── base.html │ │ ├── list │ │ │ ├── image_list.html │ │ │ └── category_list.html │ │ ├── conversation │ │ │ ├── inbox_archive.html │ │ │ ├── conversation_archive.html │ │ │ └── inbox.html │ │ ├── reporting │ │ │ └── general.html │ │ └── edit │ │ │ ├── comment_form.html │ │ │ └── entry_update.html │ ├── flatpages │ │ └── default.html │ ├── 500.html │ ├── 404.html │ └── admin │ │ ├── actions │ │ ├── unsuspend_user.html │ │ ├── suspend_user.html │ │ └── topic_move.html │ │ ├── novice_lookup.html │ │ ├── novices.html │ │ ├── sites │ │ └── clear_cache.html │ │ └── app_list.html ├── locale │ └── tr │ │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── djangojs.mo ├── static │ └── dictionary │ │ ├── img │ │ ├── favicon.ico │ │ ├── django_logo_small.svg │ │ └── django_logo_small_dark.svg │ │ └── js │ │ ├── index.js │ │ ├── category.js │ │ ├── user.js │ │ ├── common.js │ │ ├── accessibility.js │ │ ├── search.js │ │ ├── image.js │ │ ├── dialog.js │ │ └── lib │ │ └── modal.js ├── conf.py ├── utils │ ├── db.py │ ├── views.py │ ├── email.py │ ├── validators.py │ ├── admin.py │ └── decorators.py ├── signals │ ├── __init__.py │ └── messaging.py ├── urls │ ├── __init__.py │ ├── edit.py │ ├── announcements.py │ ├── list.py │ ├── user.py │ └── auth.py ├── management │ └── commands │ │ ├── __init__.py │ │ ├── spam_topics.py │ │ ├── spam_entries.py │ │ └── quicksetup.py └── tasks.py ├── dictionary_graph ├── __init__.py ├── migrations │ └── __init__.py ├── locale │ └── tr │ │ └── LC_MESSAGES │ │ └── django.mo ├── readme.md ├── urls.py ├── apps.py ├── category │ └── __init__.py ├── user │ └── __init__.py ├── topic │ ├── __init__.py │ └── action.py ├── messaging │ └── __init__.py ├── types.py ├── entry │ ├── __init__.py │ ├── list.py │ └── edit.py ├── images.py ├── autocomplete.py ├── schema.py └── utils.py ├── docker ├── prod │ ├── nginx │ │ ├── certs │ │ │ └── .gitignore │ │ ├── include │ │ │ ├── general.conf │ │ │ ├── security.conf │ │ │ ├── ssl.conf │ │ │ └── proxy.conf │ │ ├── Dockerfile │ │ ├── sites-enabled │ │ │ └── web.conf │ │ └── nginx.conf │ ├── django │ │ ├── common.yml │ │ ├── prod.Dockerfile.dockerignore │ │ └── prod.Dockerfile │ └── docker-compose.yml └── dev │ ├── common.yml │ ├── dev.Dockerfile.dockerignore │ ├── dev.Dockerfile │ └── docker-compose.yml ├── djdict ├── __init__.py ├── wsgi.py ├── celery.py └── urls.py ├── screenshots ├── desktop_snap.png ├── mobilemessages1.png ├── mobilemessages2.png ├── desktop_snap_dark.png ├── mobile_entrypermalink.png └── readme.md ├── conf ├── dev │ ├── postgres.env │ └── django.env └── prod │ ├── postgres.env │ └── django.env ├── CONTRIBUTORS ├── .stylelintrc ├── package.json ├── .pre-commit-config.yaml ├── manage.py ├── Makefile ├── .eslintrc ├── pyproject.toml ├── LICENSE ├── README.md └── .gitignore /dictionary/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /dictionary/forms/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /dictionary/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /dictionary/views/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /dictionary_graph/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /dictionary/backends/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /dictionary/middleware/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /dictionary/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /dictionary/admin/views/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /dictionary/models/managers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /dictionary/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /dictionary/backends/sessions/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /dictionary_graph/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docker/prod/nginx/certs/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /dictionary/templates/403_csrf.html: -------------------------------------------------------------------------------- 1 |

403 Forbidden

2 | -------------------------------------------------------------------------------- /djdict/__init__.py: -------------------------------------------------------------------------------- 1 | from .celery import app as celery_app 2 | 3 | __all__ = ("celery_app",) 4 | -------------------------------------------------------------------------------- /dictionary/templates/dictionary/announcements/index.html: -------------------------------------------------------------------------------- 1 | {% extends "dictionary/announcements/base.html" %} 2 | -------------------------------------------------------------------------------- /screenshots/desktop_snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/realsuayip/django-sozluk/HEAD/screenshots/desktop_snap.png -------------------------------------------------------------------------------- /screenshots/mobilemessages1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/realsuayip/django-sozluk/HEAD/screenshots/mobilemessages1.png -------------------------------------------------------------------------------- /screenshots/mobilemessages2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/realsuayip/django-sozluk/HEAD/screenshots/mobilemessages2.png -------------------------------------------------------------------------------- /screenshots/desktop_snap_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/realsuayip/django-sozluk/HEAD/screenshots/desktop_snap_dark.png -------------------------------------------------------------------------------- /conf/dev/postgres.env: -------------------------------------------------------------------------------- 1 | POSTGRES_USER=db_dictionary_user 2 | POSTGRES_PASSWORD=db_dictionary_password 3 | POSTGRES_DB=db_dictionary 4 | -------------------------------------------------------------------------------- /conf/prod/postgres.env: -------------------------------------------------------------------------------- 1 | POSTGRES_USER=db_dictionary_user 2 | POSTGRES_PASSWORD=db_dictionary_password 3 | POSTGRES_DB=db_dictionary 4 | -------------------------------------------------------------------------------- /screenshots/mobile_entrypermalink.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/realsuayip/django-sozluk/HEAD/screenshots/mobile_entrypermalink.png -------------------------------------------------------------------------------- /dictionary/locale/tr/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/realsuayip/django-sozluk/HEAD/dictionary/locale/tr/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /dictionary/locale/tr/LC_MESSAGES/djangojs.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/realsuayip/django-sozluk/HEAD/dictionary/locale/tr/LC_MESSAGES/djangojs.mo -------------------------------------------------------------------------------- /dictionary/static/dictionary/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/realsuayip/django-sozluk/HEAD/dictionary/static/dictionary/img/favicon.ico -------------------------------------------------------------------------------- /dictionary_graph/locale/tr/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/realsuayip/django-sozluk/HEAD/dictionary_graph/locale/tr/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /CONTRIBUTORS: -------------------------------------------------------------------------------- 1 | Aras Diler 2 | Emre Tuna 3 | Şuayip Üzülmez 4 | Utkucan Bıyıklı 5 | -------------------------------------------------------------------------------- /dictionary/conf.py: -------------------------------------------------------------------------------- 1 | # Notice: Dictionary settings are located in apps.py 2 | 3 | from django.apps import apps 4 | 5 | __all__ = ["settings"] 6 | 7 | settings = apps.get_app_config("dictionary") 8 | -------------------------------------------------------------------------------- /.stylelintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "stylelint-config-standard", 3 | "rules": { 4 | "indentation": [4], 5 | "number-leading-zero": "never", 6 | "no-descending-specificity": null 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /dictionary_graph/readme.md: -------------------------------------------------------------------------------- 1 | `This app serves the Graph API of django-sozluk. 2 | Currently, the API only serves utility calls for the main site and not near to be complete. 3 | So, it is not available for standalone use.` 4 | -------------------------------------------------------------------------------- /dictionary_graph/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from graphene_django.views import GraphQLView 4 | 5 | app_name = "graph" 6 | 7 | urlpatterns = [path("", GraphQLView.as_view(graphiql=True), name="endpoint")] 8 | -------------------------------------------------------------------------------- /dictionary/utils/db.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.db.models import Subquery 3 | 4 | 5 | class SubQueryCount(Subquery): 6 | template = "(SELECT count(*) FROM (%(subquery)s) _count)" 7 | output_field = models.IntegerField() 8 | -------------------------------------------------------------------------------- /dictionary_graph/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | 5 | class DictionaryApiConfig(AppConfig): 6 | name = "dictionary_graph" 7 | verbose_name = _("Dictionary API") 8 | -------------------------------------------------------------------------------- /dictionary/signals/__init__.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | from .m2m import ( 3 | update_topic_disambiguation, 4 | update_vote_rate_downvote, 5 | update_vote_rate_favorite, 6 | update_vote_rate_upvote, 7 | ) 8 | from .messaging import deliver_message 9 | -------------------------------------------------------------------------------- /dictionary_graph/category/__init__.py: -------------------------------------------------------------------------------- 1 | from graphene import ObjectType 2 | 3 | from .action import FollowCategory, SuggestCategory 4 | 5 | 6 | class CategoryMutations(ObjectType): 7 | follow = FollowCategory.Field() 8 | suggest = SuggestCategory.Field() 9 | -------------------------------------------------------------------------------- /dictionary/admin/badge.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from dictionary.models import Badge 4 | 5 | 6 | @admin.register(Badge) 7 | class BadgeAdmin(admin.ModelAdmin): 8 | list_display = ("name", "description", "url") 9 | search_fields = ("name",) 10 | -------------------------------------------------------------------------------- /dictionary_graph/user/__init__.py: -------------------------------------------------------------------------------- 1 | from graphene import ObjectType 2 | 3 | from .action import Block, Follow, ToggleTheme 4 | 5 | 6 | class UserMutations(ObjectType): 7 | block = Block.Field() 8 | follow = Follow.Field() 9 | toggle_theme = ToggleTheme.Field() 10 | -------------------------------------------------------------------------------- /dictionary/models/managers/category.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class CategoryManager(models.Manager): 5 | def get_queryset(self): 6 | return super().get_queryset().exclude(is_pseudo=True) 7 | 8 | 9 | class CategoryManagerAll(models.Manager): 10 | pass 11 | -------------------------------------------------------------------------------- /dictionary/templates/dictionary/includes/header_link.html: -------------------------------------------------------------------------------- 1 | {% if not unauthorized %} 2 |
  • 3 | {{ hlink_safename }} 4 |
  • 5 | {% endif %} 6 | -------------------------------------------------------------------------------- /screenshots/readme.md: -------------------------------------------------------------------------------- 1 | Contents of the desktop snapshot were taken from ekşi sözlük on July 12, 2020; their rights might be reserved.\ 2 | Contents of the desktop snapshot (dark) were taken from ekşi sözlük on Sept 4, 2020: their rights might be reserved. 3 | 4 | All screenshots were taken with Mozilla Firefox. 5 | -------------------------------------------------------------------------------- /docker/dev/common.yml: -------------------------------------------------------------------------------- 1 | services: 2 | python: 3 | image: sozluk-python 4 | env_file: 5 | - ../../conf/dev/django.env 6 | - ../../conf/dev/postgres.env 7 | restart: on-failure 8 | depends_on: 9 | - db 10 | - redis 11 | volumes: 12 | - ../..:/app 13 | - /app/.venv 14 | -------------------------------------------------------------------------------- /dictionary/static/dictionary/js/index.js: -------------------------------------------------------------------------------- 1 | import "./lib/autocomplete" 2 | import "./lib/dropdown" 3 | import "./lib/modal" 4 | 5 | import "./common" 6 | import "./accessibility" 7 | import "./category" 8 | import "./conversation" 9 | 10 | import "./editor" 11 | import "./entry" 12 | 13 | import "./search" 14 | import "./topic" 15 | -------------------------------------------------------------------------------- /dictionary_graph/topic/__init__.py: -------------------------------------------------------------------------------- 1 | from graphene import ObjectType 2 | 3 | from .action import FollowTopic, WishTopic 4 | from .list import TopicListQuery 5 | 6 | 7 | class TopicMutations(ObjectType): 8 | follow = FollowTopic.Field() 9 | wish = WishTopic.Field() 10 | 11 | 12 | class TopicQueries(TopicListQuery): 13 | pass 14 | -------------------------------------------------------------------------------- /dictionary/urls/__init__.py: -------------------------------------------------------------------------------- 1 | from .announcements import urlpatterns_announcements 2 | from .auth import urlpatterns_auth 3 | from .edit import urlpatterns_edit 4 | from .list import urlpatterns_list 5 | from .user import urlpatterns_user 6 | 7 | urlpatterns = urlpatterns_announcements + urlpatterns_auth + urlpatterns_edit + urlpatterns_list + urlpatterns_user 8 | -------------------------------------------------------------------------------- /docker/prod/django/common.yml: -------------------------------------------------------------------------------- 1 | services: 2 | python: 3 | image: sozluk-python 4 | env_file: 5 | - ../../../conf/prod/django.env 6 | - ../../../conf/prod/postgres.env 7 | restart: unless-stopped 8 | depends_on: 9 | - db 10 | - redis 11 | volumes: 12 | - static:/app/static 13 | - media:/app/media 14 | -------------------------------------------------------------------------------- /docker/prod/nginx/include/general.conf: -------------------------------------------------------------------------------- 1 | # robots.txt 2 | location = /robots.txt { 3 | log_not_found off; 4 | } 5 | 6 | # gzip 7 | gzip on; 8 | gzip_vary on; 9 | gzip_proxied any; 10 | gzip_comp_level 6; 11 | gzip_types text/plain text/css text/xml application/json application/javascript application/rss+xml application/atom+xml image/svg+xml; 12 | -------------------------------------------------------------------------------- /dictionary/templates/dictionary/includes/devinfo.html: -------------------------------------------------------------------------------- 1 | {# You are free to remove this file and its references. #} 2 |
    3 | 4 | 5 | django-sozluk 1.6.1 6 | 7 | Python version: 3.11 8 | 9 |
    10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "djdict", 3 | "version": "1.0.0", 4 | "browserslist": [ 5 | "since 2017-06" 6 | ], 7 | "devDependencies": { 8 | "standard": "^14.3.4", 9 | "stylelint": "^13.7.2", 10 | "stylelint-config-standard": "^20.0.0" 11 | }, 12 | "dependencies": { 13 | "@popperjs/core": "2.5.3", 14 | "dropzone": "5.7.2", 15 | "js-cookie": "3.0.0-rc.1" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /dictionary_graph/messaging/__init__.py: -------------------------------------------------------------------------------- 1 | from graphene import ObjectType 2 | 3 | from .action import ArchiveConversation, ComposeMessage, DeleteConversation, DeleteMessage 4 | 5 | 6 | class MessageMutations(ObjectType): 7 | compose = ComposeMessage.Field() 8 | delete = DeleteMessage.Field() 9 | delete_conversation = DeleteConversation.Field() 10 | archive_conversation = ArchiveConversation.Field() 11 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v6.0.0 4 | hooks: 5 | - id: trailing-whitespace 6 | - id: mixed-line-ending 7 | args: [ '--fix=lf' ] 8 | - id: end-of-file-fixer 9 | exclude: "dist\/.*" 10 | 11 | - repo: https://github.com/charliermarsh/ruff-pre-commit 12 | rev: 'v0.12.11' 13 | hooks: 14 | - id: ruff 15 | -------------------------------------------------------------------------------- /djdict/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for djdict project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/2.1/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "djdict.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /djdict/celery.py: -------------------------------------------------------------------------------- 1 | from celery import Celery 2 | 3 | app = Celery("djdict") 4 | 5 | # Using a string here means the worker doesn't have to serialize 6 | # the configuration object to child processes. 7 | # - namespace='CELERY' means all celery-related configuration keys 8 | # should have a `CELERY_` prefix. 9 | app.config_from_object("django.conf:settings", namespace="CELERY") 10 | 11 | # Load task modules from all registered Django app configs. 12 | app.autodiscover_tasks() 13 | -------------------------------------------------------------------------------- /docker/dev/dev.Dockerfile.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | package-lock.json 3 | venv 4 | .venv 5 | media 6 | .git 7 | .gitignore 8 | .env 9 | .idea 10 | .vscode 11 | .DS_Store 12 | .coverage 13 | .github 14 | celerybeat-schedule 15 | .pre-commit-config.yaml 16 | Makefile 17 | justfile 18 | README.md 19 | htmlcov 20 | .mypy_cache 21 | .pytest_cache 22 | .ruff_cache 23 | docs 24 | k8s 25 | conf 26 | docker 27 | **/*.pyc 28 | **/__pycache__ 29 | CHANGELOG 30 | screenshots 31 | package.json 32 | -------------------------------------------------------------------------------- /dictionary/backends/sessions/cached_db.py: -------------------------------------------------------------------------------- 1 | from django.contrib.sessions.backends.cached_db import SessionStore as DjangoCachedDBStore 2 | 3 | from .db import SessionStore as DictionarySessionStore 4 | 5 | KEY_PREFIX = "dictionary.cached_session" 6 | 7 | 8 | class SessionStore(DictionarySessionStore, DjangoCachedDBStore): 9 | """ 10 | Makes database sessions use cache. Implementation inspired by: 11 | https://github.com/QueraTeam/django-qsessions 12 | """ 13 | 14 | cache_key_prefix = KEY_PREFIX 15 | -------------------------------------------------------------------------------- /dictionary/templates/dictionary/announcements/detail.html: -------------------------------------------------------------------------------- 1 | {% extends "dictionary/announcements/base.html" %} 2 | 3 | {% block title %}{{ post.title }} - {{ block.super }}{% endblock %} 4 | {% load filters %} 5 | 6 | {% block crumb %} 7 | » 8 | {{ post.title }} 9 | {% endblock %} 10 | 11 | {% block innercontent %} 12 | {% include "dictionary/announcements/post.html" with notitle="yes" %} 13 | {% endblock %} 14 | 15 | {% block rightframe %}{% endblock %} 16 | -------------------------------------------------------------------------------- /docker/prod/django/prod.Dockerfile.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | package-lock.json 3 | venv 4 | .venv 5 | media 6 | .git 7 | .gitignore 8 | .env 9 | .idea 10 | .vscode 11 | .DS_Store 12 | .coverage 13 | .github 14 | celerybeat-schedule 15 | .pre-commit-config.yaml 16 | Makefile 17 | justfile 18 | README.md 19 | htmlcov 20 | .mypy_cache 21 | .pytest_cache 22 | .ruff_cache 23 | docs 24 | k8s 25 | conf 26 | docker 27 | **/*.pyc 28 | **/__pycache__ 29 | **/media 30 | **/fixtures 31 | tests 32 | CHANGELOG 33 | screenshots 34 | package.json 35 | -------------------------------------------------------------------------------- /docker/prod/nginx/include/security.conf: -------------------------------------------------------------------------------- 1 | # security headers 2 | add_header X-XSS-Protection "1; mode=block" always; 3 | add_header X-Content-Type-Options "nosniff" always; 4 | add_header Referrer-Policy "no-referrer-when-downgrade" always; 5 | add_header Content-Security-Policy "default-src 'self' http: https: ws: wss: data: blob: 'unsafe-inline'; frame-ancestors 'self';" always; 6 | add_header Permissions-Policy "interest-cohort=()" always; 7 | 8 | # . files 9 | location ~ /\.(?!well-known) { 10 | deny all; 11 | } 12 | -------------------------------------------------------------------------------- /dictionary/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.core.management.base import BaseCommand, CommandError 3 | 4 | 5 | class BaseDebugCommand(BaseCommand): 6 | def __init__(self, *args, **kwargs): 7 | if not settings.DEBUG: 8 | raise CommandError("This command is not allowed in production. Set DEBUG to False to use this command.") 9 | 10 | super().__init__(*args, **kwargs) 11 | 12 | def handle(self, *args, **options): 13 | raise NotImplementedError("Provide a handle() method yourself!") 14 | -------------------------------------------------------------------------------- /dictionary_graph/types.py: -------------------------------------------------------------------------------- 1 | from graphene_django import DjangoObjectType 2 | 3 | from dictionary.models import Author, Category, Topic 4 | 5 | 6 | class AuthorType(DjangoObjectType): 7 | class Meta: 8 | model = Author 9 | fields = ("username", "slug", "is_novice") 10 | 11 | 12 | class TopicType(DjangoObjectType): 13 | class Meta: 14 | model = Topic 15 | fields = ("title",) 16 | 17 | 18 | class CategoryType(DjangoObjectType): 19 | class Meta: 20 | model = Category 21 | fields = ("name", "slug", "description") 22 | -------------------------------------------------------------------------------- /dictionary/static/dictionary/js/category.js: -------------------------------------------------------------------------------- 1 | /* global pgettext */ 2 | 3 | import { Handler, gqlc, toggleText } from "./utils" 4 | 5 | function categoryAction (type, pk) { 6 | return gqlc({ query: `mutation{category{${type}(pk:"${pk}"){feedback}}}` }) 7 | } 8 | 9 | Handler("button.follow-category-trigger", "click", function () { 10 | categoryAction("follow", this.getAttribute("data-category-id")).then(() => { 11 | toggleText(this, pgettext("category-list", "unfollow"), pgettext("category-list", "follow")) 12 | this.classList.toggle("faded") 13 | }) 14 | }) 15 | -------------------------------------------------------------------------------- /docker/prod/nginx/include/ssl.conf: -------------------------------------------------------------------------------- 1 | ssl_protocols TLSv1 TLSv1.1 TLSv1.2; 2 | ssl_prefer_server_ciphers on; 3 | ssl_ciphers "EECDH+AESGCM:EDH+AESGCM:AES256+EECDH:AES256+EDH"; 4 | ssl_ecdh_curve secp384r1; 5 | ssl_session_cache shared:SSL:10m; 6 | ssl_session_tickets off; 7 | ssl_stapling on; 8 | ssl_stapling_verify on; 9 | 10 | add_header Strict-Transport-Security "max-age=63072000; includeSubdomains"; 11 | add_header X-Frame-Options DENY; 12 | 13 | ssl_certificate /etc/nginx/certs/server.crt; 14 | ssl_certificate_key /etc/nginx/certs/server.key; 15 | ssl_dhparam /etc/nginx/certs/dhparam.pem; 16 | -------------------------------------------------------------------------------- /dictionary/templates/flatpages/default.html: -------------------------------------------------------------------------------- 1 | {% extends 'dictionary/base.html' %} 2 | {% block title %}{{ flatpage.title }}{% endblock %} 3 | {% block content %} 4 |

    {{ flatpage.title }}

    5 |
    6 | {% if flatpage.metaflatpage.html_only %} 7 |

    {{ flatpage.content|linebreaksbr }}

    8 | {% else %} 9 | {% load filters %} 10 |

    {{ flatpage.content|formatted|linebreaksbr }}

    11 | {% endif %} 12 |
    13 | {% endblock %} 14 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "djdict.settings") 7 | try: 8 | from django.core.management import execute_from_command_line 9 | except ImportError as exc: 10 | raise ImportError( 11 | "Couldn't import Django. Are you sure it's installed and " 12 | "available on your PYTHONPATH environment variable? Did you " 13 | "forget to activate a virtual environment?" 14 | ) from exc 15 | execute_from_command_line(sys.argv) 16 | -------------------------------------------------------------------------------- /dictionary/management/commands/spam_topics.py: -------------------------------------------------------------------------------- 1 | import random 2 | import string 3 | 4 | from dictionary.management.commands import BaseDebugCommand 5 | from dictionary.models import Author, Topic 6 | 7 | 8 | class Command(BaseDebugCommand): 9 | """Spam random topics with random ascii letters, length of 15""" 10 | 11 | def handle(self, **options): 12 | size = int(input("size: ")) 13 | while size > 0: 14 | chars = "".join(random.sample(string.ascii_letters, 15)) # nosec 15 | Topic.objects.create_topic(chars, Author.objects.get(pk=1)) 16 | size -= 1 17 | -------------------------------------------------------------------------------- /dictionary/templates/dictionary/announcements/month.html: -------------------------------------------------------------------------------- 1 | {% extends "dictionary/announcements/base.html" %} 2 | {% load i18n filters %} 3 | {# Variables created outside of a {% block %} using the template tag as syntax can’t be used inside the block #} 4 | {% block title %}{% blocktrans with date_month=month|date:"F Y"|i18n_lower %}{{ date_month }} announcements{% endblocktrans %}{% endblock %} 5 | {% block crumb %} 6 | » 7 | {% blocktrans with date_month=month|date:"F Y"|i18n_lower %}{{ date_month }} announcements{% endblocktrans %} 8 | {% endblock %} 9 | -------------------------------------------------------------------------------- /dictionary_graph/entry/__init__.py: -------------------------------------------------------------------------------- 1 | from graphene import ObjectType 2 | 3 | from .action import DeleteEntry, DownvoteEntry, FavoriteEntry, PinEntry, UpvoteEntry, VoteComment 4 | from .edit import DraftEdit 5 | from .list import EntryFavoritesQuery 6 | 7 | 8 | class EntryMutations(ObjectType): 9 | delete = DeleteEntry.Field() 10 | favorite = FavoriteEntry.Field() 11 | pin = PinEntry.Field() 12 | upvote = UpvoteEntry.Field() 13 | downvote = DownvoteEntry.Field() 14 | votecomment = VoteComment.Field() 15 | edit = DraftEdit.Field() 16 | 17 | 18 | class EntryQueries(EntryFavoritesQuery): 19 | pass 20 | -------------------------------------------------------------------------------- /dictionary/models/__init__.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | from .announcements import Announcement 3 | from .author import AccountTerminationQueue, Author, BackUp, Badge, Memento, UserVerification 4 | from .category import Category, Suggestion 5 | from .entry import Comment, Entry 6 | from .flatpages import ExternalURL, MetaFlatPage 7 | from .images import Image 8 | from .m2m import DownvotedEntries, EntryFavorites, TopicFollowing, UpvotedEntries 9 | from .messaging import Conversation, ConversationArchive, Message 10 | from .reporting import GeneralReport 11 | from .topic import Topic, Wish 12 | 13 | from ..backends.sessions.db import PairedSession # isort:skip 14 | -------------------------------------------------------------------------------- /docker/prod/nginx/include/proxy.conf: -------------------------------------------------------------------------------- 1 | proxy_http_version 1.1; 2 | proxy_cache_bypass $http_upgrade; 3 | 4 | # Proxy SSL 5 | proxy_ssl_server_name on; 6 | 7 | # Proxy headers 8 | proxy_set_header Upgrade $http_upgrade; 9 | proxy_set_header Connection $connection_upgrade; 10 | proxy_set_header X-Real-IP $remote_addr; 11 | proxy_set_header Forwarded $proxy_add_forwarded; 12 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 13 | proxy_set_header X-Forwarded-Proto $scheme; 14 | proxy_set_header X-Forwarded-Host $host; 15 | proxy_set_header X-Forwarded-Port $server_port; 16 | -------------------------------------------------------------------------------- /dictionary/backends/sessions/utils.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.core.cache import cache 3 | 4 | from .cached_db import KEY_PREFIX, __name__ as cached_db_name 5 | from .db import PairedSession 6 | 7 | 8 | def flush_all_sessions(user): 9 | """Invalidate ALL sessions of a user.""" 10 | 11 | sessions = PairedSession.objects.filter(user=user) 12 | cached = settings.SESSION_ENGINE == cached_db_name 13 | 14 | for session in sessions: 15 | if cached: 16 | cache_key = KEY_PREFIX + session.session_key # Determined in DjangoCachedDBStore 17 | cache.delete(cache_key) 18 | 19 | session.delete() 20 | -------------------------------------------------------------------------------- /dictionary/urls/edit.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from dictionary.views.edit import CommentCreate, CommentUpdate, EntryCreate, EntryUpdate 4 | from dictionary.views.reporting import GeneralReportView 5 | 6 | urlpatterns_edit = [ 7 | path("entry/update//", EntryUpdate.as_view(), name="entry_update"), 8 | path("entry/create/", EntryCreate.as_view(), name="entry_create"), 9 | path("entry//comment/", CommentCreate.as_view(), name="comment_create"), 10 | path("entry/comment/edit//", CommentUpdate.as_view(), name="comment_update"), 11 | path("contact/", GeneralReportView.as_view(), name="general-report"), 12 | ] 13 | -------------------------------------------------------------------------------- /conf/dev/django.env: -------------------------------------------------------------------------------- 1 | DEBUG=1 2 | SECRET_KEY=foo 3 | DJANGO_ALLOWED_HOSTS=127.0.0.1 4 | CSRF_TRUSTED_ORIGINS=http://127.0.0.1:8000 5 | DJANGO_SETTINGS_MODULE=djdict.settings 6 | 7 | SQL_ENGINE=django.db.backends.postgresql 8 | SQL_PORT=5432 9 | SQL_HOST=db 10 | SQL_DATABASE=db_dictionary 11 | SQL_USER=db_dictionary_user 12 | SQL_PASSWORD=db_dictionary_password 13 | 14 | EMAIL_HOST=eh 15 | EMAIL_PORT=587 16 | EMAIL_HOST_USER=eh_usr 17 | EMAIL_HOST_PASSWORD=pw 18 | 19 | SESSION_ENGINE=dictionary.backends.sessions.db 20 | EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend 21 | 22 | REDIS_URL=redis://redis:6379/ 23 | RABBITMQ_URL=amqp://guest:guest@rabbitmq:5672/ 24 | -------------------------------------------------------------------------------- /conf/prod/django.env: -------------------------------------------------------------------------------- 1 | DEBUG=0 2 | SECRET_KEY=foo 3 | DJANGO_ALLOWED_HOSTS=.sozluk.me 4 | CSRF_TRUSTED_ORIGINS=https://sozluk.me 5 | DJANGO_SETTINGS_MODULE=djdict.settings 6 | 7 | SQL_ENGINE=django.db.backends.postgresql 8 | SQL_PORT=5432 9 | SQL_HOST=db 10 | SQL_DATABASE=db_dictionary 11 | SQL_USER=db_dictionary_user 12 | SQL_PASSWORD=db_dictionary_password 13 | 14 | EMAIL_HOST=eh 15 | EMAIL_PORT=587 16 | EMAIL_HOST_USER=eh_usr 17 | EMAIL_HOST_PASSWORD=pw 18 | 19 | SESSION_ENGINE=dictionary.backends.sessions.cached_db 20 | EMAIL_BACKEND=djcelery_email.backends.CeleryEmailBackend 21 | 22 | REDIS_URL=redis://redis:6379/ 23 | RABBITMQ_URL=amqp://guest:guest@rabbitmq:5672/ 24 | -------------------------------------------------------------------------------- /dictionary/templates/dictionary/registration/email/confirmation_email_template.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% url "confirm-email" token=token as confirm_url %} 3 | {% blocktrans trimmed with name=user.username %} 4 |

    dear {{ name }}

    5 |

    to confirm your e-mail, follow this link:

    6 | confirm e-mail 7 |

    if you can't see or click the link, navigate to this link via the address bar:

    8 |

    {{ protocol }}://{{ domain }}{{ confirm_url }}

    9 |

    if you didn't request such thing, you don't need to worry about anything.

    10 |

    have a good day.

    11 | {% endblocktrans %} 12 | -------------------------------------------------------------------------------- /dictionary/urls/announcements.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from dictionary.views.announcements import AnnouncementDetail, AnnouncementIndex, AnnouncementMonth 4 | 5 | urlpatterns_announcements = [ 6 | path("announcements/", AnnouncementIndex.as_view(), name="announcements-index"), 7 | path( 8 | "announcements///", 9 | AnnouncementMonth.as_view(month_format="%m"), 10 | name="announcements-month", 11 | ), 12 | path( 13 | "announcements/////", 14 | AnnouncementDetail.as_view(month_format="%m"), 15 | name="announcements-detail", 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /dictionary_graph/images.py: -------------------------------------------------------------------------------- 1 | from graphene import Mutation, ObjectType, String 2 | 3 | from dictionary.models import Image 4 | from dictionary_graph.utils import login_required 5 | 6 | 7 | class DeleteImage(Mutation): 8 | class Arguments: 9 | slug = String() 10 | 11 | feedback = String() 12 | 13 | @staticmethod 14 | @login_required 15 | def mutate(_root, info, slug): 16 | image = Image.objects.get(author=info.context.user, is_deleted=False, slug=slug) 17 | image.is_deleted = True 18 | image.save(update_fields=["is_deleted"]) 19 | return DeleteImage(feedback=None) 20 | 21 | 22 | class ImageMutations(ObjectType): 23 | delete = DeleteImage.Field() 24 | -------------------------------------------------------------------------------- /dictionary/models/managers/entry.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.db.models import Q 3 | 4 | 5 | class EntryManager(models.Manager): 6 | # Includes ONLY the PUBLISHED entries by NON-NOVICE authors 7 | def get_queryset(self): 8 | return super().get_queryset().exclude(Q(is_draft=True) | Q(author__is_novice=True)) 9 | 10 | 11 | class EntryManagerAll(models.Manager): 12 | # Includes ALL entries (entries by novices, drafts) 13 | pass 14 | 15 | 16 | class EntryManagerOnlyPublished(models.Manager): 17 | # Includes ONLY the PUBLISHED entries (entries by NOVICE users still visible) 18 | 19 | def get_queryset(self): 20 | return super().get_queryset().exclude(is_draft=True) 21 | -------------------------------------------------------------------------------- /dictionary/migrations/0002_author_unique_author_lower_email.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.9 on 2024-05-29 21:13 2 | 3 | from django.db import migrations, models 4 | import django.db.models.functions.text 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("dictionary", "0001_initial"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddConstraint( 14 | model_name="author", 15 | constraint=models.UniqueConstraint( 16 | django.db.models.functions.text.Lower("email"), 17 | name="unique_author_lower_email", 18 | violation_error_message="this e-mail is already in use.", 19 | ), 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /dictionary/templates/dictionary/registration/email/confirmation_result.html: -------------------------------------------------------------------------------- 1 | {% extends "dictionary/base.html" %} 2 | {% load i18n %} 3 | 4 | {% block title %}{% trans "e-mail confirmation" context "titleblock" %}{% endblock %} 5 | 6 | {% block content %} 7 | 15 | {% endblock %} 16 | -------------------------------------------------------------------------------- /dictionary/templates/dictionary/registration/password_reset/email_template.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% url 'password_reset_confirm' uidb64=uid token=token as confirm_url %} 3 | {% blocktrans trimmed with name=user.username %} 4 |

    dear {{ name }}

    5 |

    you requested password reset link as you somehow forget your password. here is the link:

    6 | reset my password 7 |

    if you can't see or click the link, navigate to this link via the address bar:

    8 |

    {{ protocol }}://{{ domain }}{{ confirm_url }}

    9 |

    if you didn't request such thing, you don't need to worry about anything.

    10 |

    have a good day.

    11 | {% endblocktrans %} 12 | -------------------------------------------------------------------------------- /dictionary/urls/list.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from dictionary.views.list import CategoryList, Index, TopicEntryList, TopicList 4 | 5 | urlpatterns_list = [ 6 | # Generic list 7 | path("", Index.as_view(), name="home"), 8 | path("threads//", TopicList.as_view(), name="topic_list"), 9 | path("channels/", CategoryList.as_view(), name="category_list"), 10 | # Topic entry list 11 | path("topic/", TopicEntryList.as_view(), name="topic-search"), 12 | path("topic//", TopicEntryList.as_view(), name="topic"), 13 | path("topic//", TopicEntryList.as_view(), name="topic-unicode-url"), 14 | path("entry//", 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 | 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 | 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 | 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 |
    7 | {% csrf_token %} 8 | 9 |
    10 | 11 | 12 | {% if form.errors %} 13 |

    {{ form.email.errors.0 }}

    14 | {% endif %} 15 | 16 | 17 | {% render_field form.email type="email" id="resetEmail" class="form-control" %} 18 |
    19 | 20 | 21 |
    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 | 12 | {% endfor %} 13 | {% endif %} 14 | 15 |
    16 |
    17 | 18 | {% render_field form.email class="form-control" id="emailForResend" %} 19 | {% for error in form.email.errors %} 20 |

    {{ error|escape }}

    21 | {% endfor %} 22 |
    23 | 24 | {% csrf_token %} 25 | 26 |
    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 | 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 | 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 |

    8 | {% trans "site announcements" %} 9 | {% block crumb %}{% endblock %} 10 |

    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 | 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 | {% trans 15 |
    16 |
    17 | ({% trans "image" context "editor" %}: {{ image.slug }}) 18 | {% trans "delete" %} 19 |
    20 | 21 |
    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 |
    3 |
    4 |

    5 | {{ entry.topic.title }} 6 | {% if entry.id == user.pinned_entry_id %} 7 | 8 | 9 | 10 | {% trans "unpin this from the profile" %} 11 | 12 | 13 | {% endif %} 14 |

    15 | {% with text=entry.content|formatted|linebreaksbr %} 16 | {{ text|truncatechars_html:700 }} 17 |
    18 | {% if entry.author == user %} 19 | {% trans "edit" %}{% endif %} 20 | {% if text|length > 700 %} 21 | {% trans "read more »" %} 22 | {% endif %} 23 | {{ entry.date_created|entrydate:entry.date_edited }} 24 |
    25 | {% endwith %} 26 |
    27 |
    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 |
      5 | {% for wish in wishes %} 6 |
    • 7 | {% blocktrans trimmed with naturaltime=wish.date_created|naturaltime url=wish.author.get_absolute_url username=wish.author.username %} 8 | the user {{ username }} wants this topic to be populated. it was wished {{ naturaltime }}. 9 | {% endblocktrans %} 10 | {% if wish.hint %}{% trans "hints:" %}

      {{ wish.hint|formatted|linebreaksbr }}

      {% endif %} 11 |
    • 12 | {% endfor %} 13 |
    14 | {% endwith %} 15 | 16 | 17 | {% if user.is_authenticated %} 18 |
    19 | 20 | {% trans "someone should populate this" %} 21 | {% with wished=topic|wished_by:user %} 22 | {% trans "remove the wish i asked" %} 23 | {% trans "someone should populate this" %} 24 | {% endwith %} 25 |
    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 |
    31 |
    32 | {% csrf_token %} 33 | 34 | 35 | {% trans "No, take me back" %} 36 |
    37 |
    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 |

    17 | {{ forloop.counter }}. 18 | {{ entry.topic.title }}(#{{ entry.id }}) 19 |

    20 |

    {{ entry.content }}
    {{ entry.date_created }}

    21 |
    22 |
    23 | {% endfor %} 24 | 25 |
    26 |
    27 | 28 | 33 |
    34 | 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 | 11 | 12 | 16 | 17 | 21 | 22 | 26 | 27 | 28 | 29 | {% for obj in object_list %} 30 | 31 | 32 | 33 | 34 | 35 | 36 | {% endfor %} 37 | 38 |
    8 | 9 |
    10 |
    13 | 14 |
    15 |
    18 | 19 |
    20 |
    23 | 24 |
    25 |
    {{ obj.username }}{{ obj.email }}{{ obj.application_date }}{{ obj.date_joined }}
    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(``) 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 | 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 | 26 | {% if user.is_authenticated %} 27 | {% trans "unfollow,follow" context "category-list" as state %} 28 | 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 | 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 | 10 | {% endblock %} 11 | 12 | {% block content %} 13 |
    14 |
    15 | 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](https://img.shields.io/badge/License-BSD%203--Clause-blue.svg)](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 | 10 | {% endif %} 11 | 12 |
    13 | {{ form.non_field_erros }} 14 | {% with WIDGET_ERROR_CLASS='is-invalid' %} 15 |
    16 | 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 | 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 | 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 | 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 | 10 | {% for model in app.models %} 11 | 12 | {% if model.admin_url %} 13 | 14 | {% else %} 15 | 16 | {% endif %} 17 | 18 | {% if model.add_url %} 19 | 20 | {% else %} 21 | 22 | {% endif %} 23 | 24 | {% if model.admin_url and show_changelinks %} 25 | {% if model.view_only %} 26 | 27 | {% else %} 28 | 29 | {% endif %} 30 | {% elif show_changelinks %} 31 | 32 | {% endif %} 33 | 34 | {% endfor %} 35 |
    8 | {{ app.name }} 9 |
    {{ model.name }}{{ model.name }}{% translate 'Add' %}{% translate 'View' %}{% translate 'Change' %}
    36 |
    37 | {% endfor %} 38 | {% else %} 39 |

    {% translate 'You don’t have permission to view or edit anything.' %}

    40 | {% endif %} 41 | 42 |
    43 | 44 | 45 | 46 | {% if perms.dictionary.can_activate_user %} 47 | 48 | {% endif %} 49 | {% if perms.dictionary.can_clear_cache %} 50 | 51 | {% endif %} 52 | 53 |
    {% translate "Other actions" %}
    {% translate "Novice review list" %}
    {% translate "Clear cache" %}
    54 |
    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 | 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 | 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 | 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 | 26 | {% endif %} 27 | 28 |
    29 |
    30 | 31 | {% render_field form.email1 id="email_1" class="form-control" %} 32 | 33 | {% for err in form.email1.errors %} 34 |

    {{ err }}

    35 | {% endfor %} 36 |
    37 | 38 |
    39 | 40 | {% render_field form.email2 id="email_2" class="form-control" %} 41 | 42 | {% for err in form.email1.errors %} 43 |

    {{ err }}

    44 | {% endfor %} 45 |
    46 | 47 |
    48 | 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 | 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 |
      35 | {% with messages=object.to_json.messages %} 36 | {% if messages|length > 10 %} 37 | {% trans "only the last 10 messages are shown. click here to show all messages." %} 38 | {% endif %} 39 | 40 | {% for message in messages %} 41 |
    • 42 |

      {{ message.body|formatted|linebreaksbr }}

      43 | {# Translators: Archived conversation timestamp #} 44 | 45 |
    • 46 | {% endfor %} 47 | {% endwith %} 48 |
    49 | {% endblock %} 50 | -------------------------------------------------------------------------------- /dictionary/templates/dictionary/includes/editor_buttons.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 |
    3 |
    4 | {# Translators: Editor button (see) #} 5 | 6 | 7 | 8 | 9 | 10 |
    11 | {% if not user.is_novice %} 12 | 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 |
    1. {% blocktrans with faq_url="/faq/" %}please visit frequently asked questions page before submitting reports.{% endblocktrans %}
    2. 10 |
    3. {% trans "please state your requests and complaints in a comprehensible and concise way." %}
    4. 11 |
    5. {% trans "we will not notify other authors if you decide to report them, this is done anonymously." %}
    6. 12 |
    7. {% trans "provide an e-mail address for confirmation and feedback. if you are logged in already, we will use your registered e-mail address." %}
    8. 13 |
    9. {% trans "if have multiple statements, please consider them all in a report instead of sending them in seperate reports." %}
    10. 14 |
    15 | 16 |
    17 | {% if not user.is_authenticated %} 18 |
    19 | 20 | {% render_field form.reporter_email class="form-control" id="reporter_email" %} 21 |
    22 | {% else %} 23 | 24 | {% endif %} 25 | 26 |
    27 | 28 | {% render_field form.category class="form-control" id="report_category" %} 29 |
    30 |
    31 | 32 | {% render_field form.subject|attr:"autofocus" class="form-control" id="reporter_subject" %} 33 |
    34 |
    35 | 36 | {% render_field form.content rows="5" class="form-control" id="reporter_content" %} 37 |
    38 | 39 | {% csrf_token %} 40 | 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 |

    9 | {{ entry.topic.title }} 10 | #{{ entry.pk }} {{ updating|yesno:_(" - updating comment, - commenting") }} 11 |

    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 | 28 | {% endif %} 29 |
    30 | {% include "dictionary/includes/editor_buttons.html" %} 31 |
    32 | {% csrf_token %} 33 | {% blocktrans asvar text_placeholder with title=entry.topic.title %}comment on this entry written in {{ title }}...{% endblocktrans %} 34 | {% trans "Comment content area" as area_label %} 35 | {% render_field form.content|attr:"autofocus" placeholder=text_placeholder id="user_content_edit" class="entry_editor form-control" rows="10" spellcheck="true" aria-label=area_label %} 36 | 37 |
    38 | {% if updating %} 39 | 40 | {% endif %} 41 | 42 |
    43 |
    44 |
    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 |

    7 | {{ entry.topic.title }} 8 | #{{ entry.pk }} 9 |

    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 | 21 | {% endif %} 22 | 23 |
    24 | {% include "dictionary/includes/editor_buttons.html" %} 25 | 26 |
    27 | {% csrf_token %} 28 | {% autoescape off %} 29 | {# Notice: This string gets escaped in render_field! #} 30 | {% blocktrans asvar text_placeholder with title=entry.topic.title %}express your thoughts on {{ title }}..{% endblocktrans %} 31 | {% endautoescape %} 32 | {% trans "Entry content area" as area_label %} 33 | {% render_field form.content|attr:"autofocus" placeholder=text_placeholder id="user_content_edit" class="entry_editor form-control allowsave" rows="20" spellcheck="true" aria-label=area_label %} 34 | 35 |
    36 |
    37 | {% if entry.is_draft %} 38 | 39 | {% endif %} 40 | 41 |
    42 | 43 |
    44 |
    45 |
    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(`${gettext(`) 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 | 34 |
    35 | 36 | 43 |
    44 | 45 |
    46 | 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 |
    12 | {% for err in form.non_field_errors %} 13 |
    {{ err }}
    14 | {% endfor %} 15 | 16 |
    17 | 18 | {% render_field form.new_password1 class="form-control" id="pref_new_password1" %} 19 | 20 | {% if form.new_password1.errors %} 21 |
      22 | {% for error in form.new_password1.errors %} 23 |
    • {{ error }}
    • 24 | {% endfor %} 25 |
    26 | {% endif %} 27 | {{ form.new_password1.help_text }} 28 |
    29 | 30 |
    31 | 32 | {% render_field form.new_password2 class="form-control" id="pref_new_password2" %} 33 | 34 | {% if form.new_password2.errors %} 35 |
      36 | {% for error in form.new_password2.errors %} 37 |
    • {{ error }}
    • 38 | {% endfor %} 39 |
    40 | {% endif %} 41 |
    42 |
    43 | 44 | 45 |
    46 | {% else %} 47 | {% url "password_reset" as password_reset_url %} 48 | 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 | 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 | 35 | 36 |
    37 | 38 |
    39 |

    {% trans "Give a date interval for entries (optional):" %}

    40 |
    41 | 42 | {# Translators: Day should come first. It should be a parsable date. #} 43 | 44 |
    45 |
    46 |
    47 | 48 | 49 |
    50 |
    51 | 52 |
    53 | 54 | 55 |
    56 | 57 |
    58 | {% csrf_token %} 59 | 60 |
    61 |
    62 | 63 | {% endblock %} 64 | --------------------------------------------------------------------------------