├── users ├── migrations │ ├── __init__.py │ ├── 0004_constraints.py │ ├── 0003_apikey.py │ ├── 0002_userprofile_token.py │ └── 0001_squashed.py ├── templatetags │ ├── __init__.py │ └── user_tags.py ├── apps.py ├── tests │ ├── __init__.py │ ├── test_backends.py │ ├── test_utils.py │ ├── test_tags.py │ ├── conftest.py │ └── fixtures │ │ ├── series.json │ │ └── users.json ├── __init__.py ├── templates │ ├── delete.html │ ├── bookmarks.html │ └── edit_user.html ├── urls.py ├── backends.py └── adapters.py ├── MangAdventure ├── tests │ ├── __init__.py │ ├── base.py │ ├── test_middleware.py │ ├── test_forms.py │ ├── test_validators.py │ ├── test_sitemaps.py │ ├── test_storage.py │ └── test_cache.py ├── __init__.py ├── templates │ ├── account │ │ ├── email │ │ │ ├── password_reset_key_subject.txt │ │ │ ├── email_confirmation_subject.txt │ │ │ ├── email_confirmation_signup_subject.txt │ │ │ ├── password_reset_key_message.txt │ │ │ ├── email_confirmation_message.txt │ │ │ └── email_confirmation_signup_message.txt │ │ ├── account_inactive.html │ │ ├── password_reset_from_key_done.html │ │ ├── password_reset_done.html │ │ ├── verification_sent.html │ │ ├── email_confirm.html │ │ ├── password_reset.html │ │ ├── password_reset_from_key.html │ │ └── signup.html │ ├── error.html │ ├── socialaccount │ │ └── authentication_error.html │ ├── contribute.json │ ├── opensearch.xml │ ├── image-sitemap.xml │ ├── manifest.webmanifest │ ├── flatpages │ │ └── default.html │ ├── footer.html │ └── index.html ├── wsgi.py ├── __main__.py ├── forms.py ├── converters.py ├── widgets.py ├── middleware.py ├── fields.py ├── sitemaps.py ├── urls.py ├── jsonld.py └── bad_bots.py ├── config ├── migrations │ ├── __init__.py │ ├── 0002_scanlator_permissions.py │ └── 0001_initial.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ ├── clearcache.py │ │ ├── createsuperuser.py │ │ └── logs.py ├── __init__.py ├── templatetags │ ├── __init__.py │ └── flatpage_tags.py ├── tests │ ├── __init__.py │ ├── conftest.py │ ├── test_urls.py │ ├── fixtures │ │ ├── info_pages.json │ │ └── users.json │ ├── test_admin.py │ └── test_fs2import.py ├── urls.py ├── apps.py └── context_processors.py ├── groups ├── migrations │ ├── __init__.py │ ├── 0003_alter_group_id.py │ ├── 0004_constraints.py │ └── 0002_managers.py ├── __init__.py ├── templatetags │ ├── __init__.py │ └── group_tags.py ├── tests │ ├── __init__.py │ ├── test_tags.py │ ├── test_sitemaps.py │ ├── conftest.py │ ├── test_views.py │ ├── fixtures │ │ └── users.json │ └── test_feeds.py ├── apps.py ├── sitemaps.py ├── urls.py ├── api.py └── views.py ├── reader ├── migrations │ ├── __init__.py │ ├── 0007_series_licensed.py │ ├── 0008_chapter_views.py │ ├── 0006_file_limits.py │ ├── 0005_managers.py │ ├── 0003_chapter_published.py │ ├── 0011_series_status.py │ ├── 0002_series_created.py │ ├── 0010_null_volumes.py │ └── 0009_constraints.py ├── __init__.py ├── tests │ ├── __init__.py │ ├── conftest.py │ ├── test_sitemaps.py │ └── fixtures │ │ └── users.json ├── apps.py ├── urls.py └── sitemaps.py ├── manage.py ├── api ├── __init__.py ├── v2 │ ├── __init__.py │ ├── apps.py │ ├── negotiation.py │ ├── auth.py │ ├── mixins.py │ ├── urls.py │ └── pagination.py ├── v1 │ ├── __init__.py │ ├── apps.py │ └── urls.py ├── tests │ ├── __init__.py │ ├── fixtures │ │ ├── authors_artists.json │ │ ├── groups.json │ │ ├── chapters.json │ │ └── series.json │ ├── conftest.py │ └── test_v2.py └── urls.py ├── docs ├── _static │ ├── css │ │ └── style.css │ └── logo.png ├── api │ ├── artists │ │ ├── index.rst │ │ ├── all.rst │ │ └── single.rst │ ├── authors │ │ ├── index.rst │ │ ├── all.rst │ │ └── single.rst │ ├── groups │ │ ├── index.rst │ │ ├── all.rst │ │ └── single.rst │ ├── series │ │ ├── index.rst │ │ ├── chapter.rst │ │ └── volume.rst │ ├── includes │ │ ├── status.rst │ │ ├── chapter.rst │ │ ├── headers-etag.rst │ │ ├── series.rst │ │ ├── group.rst │ │ └── headers-modified.rst │ ├── index.rst │ ├── categories.rst │ └── releases.rst ├── modules │ ├── index.rst │ ├── config.management.rst │ ├── api.rst │ ├── users.templatetags.rst │ ├── groups.templatetags.rst │ ├── config.templatetags.rst │ ├── api.v1.rst │ ├── config.rst │ ├── config.management.commands.rst │ ├── api.v2.rst │ ├── groups.rst │ ├── reader.rst │ └── users.rst ├── index.rst ├── .readthedocs.yaml ├── examples │ ├── nginx.conf │ └── apache.conf └── Makefile ├── setup.py ├── MANIFEST.in ├── static ├── styles │ ├── tinymce.scss │ ├── noscript.css │ └── _mixins.scss └── scripts │ ├── bookmark.js │ ├── tinymce-init.js │ └── chapter.js ├── scripts ├── lint.sh └── deploy.sh ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── question.yml │ ├── feature-request.yml │ └── bug-report.yml ├── PULL_REQUEST_TEMPLATE.md ├── workflows │ ├── deploy.yml │ └── scans.yml ├── SECURITY.md └── CODE_OF_CONDUCT.md ├── .gitattributes ├── .editorconfig ├── docker ├── uwsgi.ini └── Dockerfile ├── LICENSE ├── .gitignore ├── render.yaml └── .dockerignore /users/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MangAdventure/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /config/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /groups/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /reader/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | MangAdventure/__main__.py -------------------------------------------------------------------------------- /api/__init__.py: -------------------------------------------------------------------------------- 1 | """The app that handles the API.""" 2 | -------------------------------------------------------------------------------- /groups/__init__.py: -------------------------------------------------------------------------------- 1 | """The app that handles groups.""" 2 | -------------------------------------------------------------------------------- /api/v2/__init__.py: -------------------------------------------------------------------------------- 1 | """The second version of the API.""" 2 | -------------------------------------------------------------------------------- /config/management/__init__.py: -------------------------------------------------------------------------------- 1 | """Config app management.""" 2 | -------------------------------------------------------------------------------- /config/__init__.py: -------------------------------------------------------------------------------- 1 | """The app that handles configuration.""" 2 | -------------------------------------------------------------------------------- /config/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | """Config app template tags.""" 2 | -------------------------------------------------------------------------------- /groups/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | """Groups app template tags.""" 2 | -------------------------------------------------------------------------------- /reader/__init__.py: -------------------------------------------------------------------------------- 1 | """The app that handles configuration.""" 2 | -------------------------------------------------------------------------------- /users/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | """Users app template tags.""" 2 | -------------------------------------------------------------------------------- /config/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | """Config app commands.""" 2 | -------------------------------------------------------------------------------- /docs/_static/css/style.css: -------------------------------------------------------------------------------- 1 | tbody, thead { text-align: center !important } 2 | -------------------------------------------------------------------------------- /docs/_static/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mangadventure/MangAdventure/HEAD/docs/_static/logo.png -------------------------------------------------------------------------------- /MangAdventure/__init__.py: -------------------------------------------------------------------------------- 1 | """A simple manga hosting CMS written in Django.""" 2 | 3 | __version__ = '0.9.6' 4 | -------------------------------------------------------------------------------- /api/v1/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | The original version of the API. 3 | 4 | .. deprecated:: 0.7.4 5 | Use ``api.v2`` instead. 6 | """ 7 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from setuptools import find_packages, setup 4 | 5 | setup(packages=find_packages()) 6 | -------------------------------------------------------------------------------- /MangAdventure/templates/account/email/password_reset_key_subject.txt: -------------------------------------------------------------------------------- 1 | {% load custom_tags %} 2 | {% autoescape off %} 3 | {{ config.NAME }} Password reset 4 | {% endautoescape %} 5 | -------------------------------------------------------------------------------- /docs/api/artists/index.rst: -------------------------------------------------------------------------------- 1 | Artists 2 | ======= 3 | 4 | .. toctree:: 5 | :maxdepth: 2 6 | 7 | all 8 | 9 | .. toctree:: 10 | :maxdepth: 2 11 | 12 | single 13 | -------------------------------------------------------------------------------- /docs/api/authors/index.rst: -------------------------------------------------------------------------------- 1 | Authors 2 | ======= 3 | 4 | .. toctree:: 5 | :maxdepth: 2 6 | 7 | all 8 | 9 | .. toctree:: 10 | :maxdepth: 2 11 | 12 | single 13 | -------------------------------------------------------------------------------- /docs/api/groups/index.rst: -------------------------------------------------------------------------------- 1 | Groups 2 | ====== 3 | 4 | .. toctree:: 5 | :maxdepth: 2 6 | 7 | all 8 | 9 | .. toctree:: 10 | :maxdepth: 2 11 | 12 | single 13 | -------------------------------------------------------------------------------- /MangAdventure/templates/account/email/email_confirmation_subject.txt: -------------------------------------------------------------------------------- 1 | {% load custom_tags %} 2 | {% autoescape off %} 3 | {{ config.NAME }} E-mail verification 4 | {% endautoescape %} 5 | -------------------------------------------------------------------------------- /MangAdventure/templates/account/email/email_confirmation_signup_subject.txt: -------------------------------------------------------------------------------- 1 | {% load custom_tags %} 2 | {% autoescape off %} 3 | {{ config.NAME }} Account activation 4 | {% endautoescape %} 5 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include static/scripts * 2 | recursive-include static/styles * 3 | recursive-include static/vendor * 4 | recursive-include */templates * 5 | recursive-exclude */tests * 6 | -------------------------------------------------------------------------------- /api/tests/__init__.py: -------------------------------------------------------------------------------- 1 | from pytest import mark 2 | 3 | from MangAdventure.tests.base import MangadvTestBase 4 | 5 | 6 | @mark.usefixtures('django_db_setup') 7 | class APITestBase(MangadvTestBase): 8 | pass 9 | -------------------------------------------------------------------------------- /groups/tests/__init__.py: -------------------------------------------------------------------------------- 1 | from pytest import mark 2 | 3 | from MangAdventure.tests.base import MangadvTestBase 4 | 5 | 6 | @mark.usefixtures('django_db_setup') 7 | class GroupsTestBase(MangadvTestBase): 8 | pass 9 | -------------------------------------------------------------------------------- /static/styles/tinymce.scss: -------------------------------------------------------------------------------- 1 | @import 'variables'; 2 | 3 | .mce-content-body { 4 | background-color: $main-bg; 5 | color: $main-fg; 6 | font-family: quote(#{$font-family}), 'Helvetica', 'Arial', sans-serif; 7 | } 8 | -------------------------------------------------------------------------------- /scripts/lint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | declare -i e=0 4 | 5 | flake8 || ((e+=1)) 6 | isort -q -c --df . || ((e+=2)) 7 | mypy --no-error-summary . || ((e+=4)) 8 | bandit -q -c pyproject.toml -r . || ((e+=8)) 9 | 10 | exit $e 11 | -------------------------------------------------------------------------------- /docs/modules/index.rst: -------------------------------------------------------------------------------- 1 | Modules 2 | ======= 3 | 4 | MangAdventure modules reference for developers. 5 | 6 | .. toctree:: 7 | :maxdepth: 4 8 | 9 | MangAdventure 10 | api 11 | config 12 | groups 13 | reader 14 | users 15 | -------------------------------------------------------------------------------- /api/v1/apps.py: -------------------------------------------------------------------------------- 1 | """App configuration.""" 2 | 3 | from django.apps import AppConfig 4 | 5 | 6 | class ApiV1Config(AppConfig): 7 | """Configuration for the api.v1 app.""" 8 | #: The name of the app. 9 | name = 'api.v1' 10 | 11 | 12 | __all__ = ['ApiV1Config'] 13 | -------------------------------------------------------------------------------- /api/v2/apps.py: -------------------------------------------------------------------------------- 1 | """App configuration.""" 2 | 3 | from django.apps import AppConfig 4 | 5 | 6 | class ApiV2Config(AppConfig): 7 | """Configuration for the api.v2 app.""" 8 | #: The name of the app. 9 | name = 'api.v2' 10 | 11 | 12 | __all__ = ['ApiV2Config'] 13 | -------------------------------------------------------------------------------- /users/apps.py: -------------------------------------------------------------------------------- 1 | """App configuration.""" 2 | 3 | from django.apps import AppConfig 4 | 5 | 6 | class UsersConfig(AppConfig): 7 | """Configuration for the users app.""" 8 | #: The name of the app. 9 | name = 'users' 10 | 11 | 12 | __all__ = ['UsersConfig'] 13 | -------------------------------------------------------------------------------- /groups/apps.py: -------------------------------------------------------------------------------- 1 | """App configuration.""" 2 | 3 | from django.apps import AppConfig 4 | 5 | 6 | class GroupsConfig(AppConfig): 7 | """Configuration for the groups app.""" 8 | #: The name of the app. 9 | name = 'groups' 10 | 11 | 12 | __all__ = ['GroupsConfig'] 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://json.schemastore.org/github-issue-config.json 2 | blank_issues_enabled: false 3 | contact_links: 4 | - name: 💬 Discord Server 5 | url: https://discord.gg/GsJyhSz 6 | about: Join our server for support, or just to chat. 7 | -------------------------------------------------------------------------------- /docs/api/series/index.rst: -------------------------------------------------------------------------------- 1 | Series 2 | ====== 3 | 4 | .. toctree:: 5 | :maxdepth: 2 6 | 7 | all 8 | 9 | .. toctree:: 10 | :maxdepth: 2 11 | 12 | single 13 | 14 | .. toctree:: 15 | :maxdepth: 2 16 | 17 | volume 18 | 19 | .. toctree:: 20 | :maxdepth: 2 21 | 22 | chapter 23 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | 3 | docs/* linguist-documentation 4 | 5 | static/vendor/* linguist-vendored 6 | static/admin/* linguist-vendored 7 | static/django_tinymce/* linguist-vendored 8 | 9 | static/COMPILED/* linguist-generated 10 | 11 | .env.example linguist-detectable=false linguist-language=Shell 12 | -------------------------------------------------------------------------------- /api/tests/fixtures/authors_artists.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "model": "reader.author", 4 | "pk": 1, 5 | "fields": { 6 | "name": "Test Author" 7 | } 8 | }, 9 | { 10 | "model": "reader.artist", 11 | "pk": 1, 12 | "fields": { 13 | "name": "Test Artist" 14 | } 15 | } 16 | ] 17 | -------------------------------------------------------------------------------- /docs/api/includes/status.rst: -------------------------------------------------------------------------------- 1 | Status Codes 2 | ~~~~~~~~~~~~ 3 | 4 | * `200 OK `_ 5 | - No error. 6 | * `304 Not Modified `_ 7 | - The request was conditional and the content hasn't been modified. 8 | -------------------------------------------------------------------------------- /docs/modules/config.management.rst: -------------------------------------------------------------------------------- 1 | config.management package 2 | ========================= 3 | 4 | .. automodule:: config.management 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | 9 | Subpackages 10 | ----------- 11 | 12 | .. toctree:: 13 | :maxdepth: 4 14 | 15 | config.management.commands 16 | -------------------------------------------------------------------------------- /MangAdventure/wsgi.py: -------------------------------------------------------------------------------- 1 | """WSGI definitions.""" 2 | 3 | from os import environ as env 4 | 5 | from django.core.wsgi import get_wsgi_application 6 | 7 | env.setdefault('DJANGO_SETTINGS_MODULE', 'MangAdventure.settings') 8 | 9 | #: Django's WSGI application instance. 10 | application = get_wsgi_application() 11 | 12 | __all__ = ['application'] 13 | -------------------------------------------------------------------------------- /docs/api/index.rst: -------------------------------------------------------------------------------- 1 | API 2 | === 3 | 4 | .. attention:: 5 | 6 | | API v1 is deprecated and disabled by default. 7 | | API v2 documentation is available per-site at ``/api/v2/docs/``. 8 | 9 | .. toctree:: 10 | :maxdepth: 2 11 | 12 | releases 13 | series/index 14 | authors/index 15 | artists/index 16 | groups/index 17 | categories 18 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | MangAdventure 2 | ============= 3 | 4 | | MangAdventure, AKA MangADV, is a simple manga hosting CMS. 5 | | It is fully written in Django, SCSS and Vanilla JS. No PHP, no Node.js, no jQuery. 6 | 7 | .. toctree:: 8 | :maxdepth: 2 9 | 10 | install 11 | api/index 12 | modules/index 13 | compatibility 14 | changelog 15 | roadmap 16 | -------------------------------------------------------------------------------- /MangAdventure/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from os import environ 4 | from sys import argv 5 | 6 | 7 | def run(): 8 | environ['DJANGO_SETTINGS_MODULE'] = 'MangAdventure.settings' 9 | from django.core.management import execute_from_command_line 10 | execute_from_command_line(argv) 11 | 12 | 13 | if __name__ == '__main__': run() # noqa: E701 14 | -------------------------------------------------------------------------------- /MangAdventure/templates/account/email/password_reset_key_message.txt: -------------------------------------------------------------------------------- 1 | {% with site_domain=current_site.domain %} 2 | Hello, {{ username }}, 3 | 4 | You have requested to reset your password on {{ site_domain }}. 5 | To do so, please click the link below. 6 | If you didn't make this request, you can ignore this e-mail. 7 | 8 | {{ password_reset_url }} 9 | {% endwith %} 10 | -------------------------------------------------------------------------------- /api/urls.py: -------------------------------------------------------------------------------- 1 | """The main URLconf of the api app.""" 2 | 3 | from django.urls import include, path 4 | 5 | #: The URL namespace of the API app. 6 | app_name = 'api' 7 | 8 | #: The main URL patterns of the API app. 9 | urlpatterns = [ 10 | path('v1/', include('api.v1.urls')), 11 | path('v2/', include('api.v2.urls')), 12 | ] 13 | 14 | __all__ = ['urlpatterns'] 15 | -------------------------------------------------------------------------------- /reader/tests/__init__.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | 3 | from pytest import mark 4 | 5 | from MangAdventure.tests.base import MangadvTestBase 6 | 7 | 8 | @mark.usefixtures('django_db_setup') 9 | class ReaderTestBase(MangadvTestBase): 10 | def setup_method(self): 11 | super().setup_method() 12 | self.user = User.objects.get(pk=1) 13 | -------------------------------------------------------------------------------- /api/tests/fixtures/groups.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "model": "groups.group", 4 | "pk": 1, 5 | "fields": { 6 | "name": "Test Group", 7 | "website": "", 8 | "description": "", 9 | "email": "", 10 | "discord": "", 11 | "twitter": "", 12 | "irc": "", 13 | "reddit": "", 14 | "logo": "test.png" 15 | } 16 | } 17 | ] 18 | -------------------------------------------------------------------------------- /static/styles/noscript.css: -------------------------------------------------------------------------------- 1 | #search-categories { display: none !important } 2 | #placeholder { display: none !important } 3 | #page-image { display: block !important } 4 | #dropdowns, #controls { display: block !important } 5 | 6 | @media screen and (max-width: 690px) { 7 | #search .s-hidden { display: inline !important } 8 | #result-table { border-right-style: solid } 9 | } 10 | -------------------------------------------------------------------------------- /config/tests/__init__.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | 3 | from pytest import mark 4 | 5 | from MangAdventure.tests.base import MangadvTestBase 6 | 7 | 8 | @mark.usefixtures('django_db_setup') 9 | class ConfigTestBase(MangadvTestBase): 10 | def setup_method(self): 11 | super().setup_method() 12 | self.user = User.objects.get(pk=1) 13 | self.client.force_login(self.user) 14 | -------------------------------------------------------------------------------- /docs/.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | build: 3 | os: ubuntu-22.04 4 | tools: 5 | python: '3.8' 6 | jobs: 7 | post_install: 8 | - cp .env.example .env 9 | - mkdir static/extra 10 | - touch static/extra/style.scss 11 | python: 12 | install: 13 | - path: . 14 | extra_requirements: [docs] 15 | sphinx: 16 | builder: html 17 | configuration: docs/conf.py 18 | -------------------------------------------------------------------------------- /reader/apps.py: -------------------------------------------------------------------------------- 1 | """App configuration.""" 2 | 3 | from django.apps import AppConfig 4 | 5 | 6 | class ReaderConfig(AppConfig): 7 | """Configuration for the users app.""" 8 | #: The name of the app. 9 | name = 'reader' 10 | 11 | def ready(self): 12 | """Register the :mod:`~reader.receivers` when the app is ready.""" 13 | __import__('reader.receivers') 14 | 15 | 16 | __all__ = ['ReaderConfig'] 17 | -------------------------------------------------------------------------------- /api/tests/fixtures/chapters.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "model": "reader.chapter", 4 | "pk": 1, 5 | "fields": { 6 | "title": "my chapter", 7 | "number": 0, 8 | "volume": 1, 9 | "series": 1, 10 | "file": "", 11 | "final": false, 12 | "published": "2019-12-31T13:36:20.264Z", 13 | "modified": "2019-12-31T13:36:20.264Z", 14 | "groups": [ 15 | 1 16 | ] 17 | } 18 | } 19 | ] 20 | -------------------------------------------------------------------------------- /groups/tests/test_tags.py: -------------------------------------------------------------------------------- 1 | from pytest import mark 2 | 3 | from groups.models import Group, Member, Role 4 | from groups.templatetags.group_tags import group_roles 5 | 6 | 7 | @mark.django_db 8 | def test_group_roles(): 9 | group = Group.objects.create(name='test') 10 | member = Member.objects.create(name='member') 11 | Role.objects.create(group=group, member=member, role='LD') 12 | assert group_roles(member, group) == 'Leader' 13 | -------------------------------------------------------------------------------- /users/tests/__init__.py: -------------------------------------------------------------------------------- 1 | from pytest import mark 2 | 3 | from MangAdventure.tests.base import MangadvTestBase 4 | 5 | from reader.models import Series 6 | from users.admin import User 7 | 8 | 9 | @mark.usefixtures('django_db_setup') 10 | class UsersTestBase(MangadvTestBase): 11 | def setup_method(self): 12 | super().setup_method() 13 | self.series = Series.objects.get(pk=1) 14 | self.user = User.objects.get(pk=1) 15 | -------------------------------------------------------------------------------- /docs/modules/api.rst: -------------------------------------------------------------------------------- 1 | api package 2 | =========== 3 | 4 | .. automodule:: api 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | 9 | Subpackages 10 | ----------- 11 | 12 | .. toctree:: 13 | :maxdepth: 4 14 | 15 | api.v1 16 | api.v2 17 | 18 | Submodules 19 | ---------- 20 | 21 | api.urls module 22 | --------------- 23 | 24 | .. automodule:: api.urls 25 | :members: 26 | :undoc-members: 27 | :show-inheritance: 28 | -------------------------------------------------------------------------------- /docs/modules/users.templatetags.rst: -------------------------------------------------------------------------------- 1 | users.templatetags package 2 | ========================== 3 | 4 | .. automodule:: users.templatetags 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | 9 | Submodules 10 | ---------- 11 | 12 | users.templatetags.user\_tags module 13 | ------------------------------------ 14 | 15 | .. automodule:: users.templatetags.user_tags 16 | :members: 17 | :undoc-members: 18 | :show-inheritance: 19 | -------------------------------------------------------------------------------- /MangAdventure/templates/account/email/email_confirmation_message.txt: -------------------------------------------------------------------------------- 1 | {% load account %} 2 | {% user_display user as user_display %} 3 | {% autoescape off %} 4 | {% with site_name=current_site.name site_domain=current_site.domain %} 5 | Hello, {{ user_display }}, 6 | 7 | Thank you for registering your e-mail to {{ site_name }}. 8 | Please click the following link to confirm your e-mail: 9 | 10 | {{ activate_url }} 11 | {% endwith %} 12 | {% endautoescape %} 13 | -------------------------------------------------------------------------------- /docs/modules/groups.templatetags.rst: -------------------------------------------------------------------------------- 1 | groups.templatetags package 2 | =========================== 3 | 4 | .. automodule:: groups.templatetags 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | 9 | Submodules 10 | ---------- 11 | 12 | groups.templatetags.group\_tags module 13 | -------------------------------------- 14 | 15 | .. automodule:: groups.templatetags.group_tags 16 | :members: 17 | :undoc-members: 18 | :show-inheritance: 19 | -------------------------------------------------------------------------------- /users/tests/test_backends.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | 3 | from . import UsersTestBase 4 | 5 | 6 | class TestScanlationBackend(UsersTestBase): 7 | def test_scanlator(self): 8 | user = User.objects.get(pk=2) 9 | assert user.has_perm('edit', self.series) 10 | 11 | def test_inactive(self): 12 | user = User.objects.get(pk=2) 13 | user.is_active = False 14 | assert not user.has_perm('read') 15 | -------------------------------------------------------------------------------- /users/tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | 3 | from users import get_user_display 4 | 5 | from . import UsersTestBase 6 | 7 | 8 | class TestInitPy(UsersTestBase): 9 | def test_get_user_display_name(self): 10 | assert get_user_display(self.user) == 'evangelos ch' 11 | 12 | def test_get_user_display_username(self): 13 | user = User.objects.get(pk=2) 14 | assert get_user_display(user) == 'obs' 15 | -------------------------------------------------------------------------------- /MangAdventure/templates/account/email/email_confirmation_signup_message.txt: -------------------------------------------------------------------------------- 1 | {% load account %} 2 | {% user_display user as user_display %} 3 | {% autoescape off %} 4 | {% with site_name=current_site.name site_domain=current_site.domain %} 5 | Hello, {{ user_display }}, 6 | 7 | Thank you for registering an account on {{ site_name }}. 8 | Please click the following link to activate your account: 9 | 10 | {{ activate_url }} 11 | {% endwith %} 12 | {% endautoescape %} 13 | -------------------------------------------------------------------------------- /reader/migrations/0007_series_licensed.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations, models 2 | 3 | 4 | class Migration(migrations.Migration): 5 | dependencies = [('reader', '0006_file_limits')] 6 | 7 | operations = [ 8 | migrations.AddField( 9 | model_name='series', 10 | name='licensed', 11 | field=models.BooleanField( 12 | default=False, help_text='Is the series licensed?' 13 | ), 14 | ), 15 | ] 16 | -------------------------------------------------------------------------------- /docs/examples/nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | server_name my-site.com www.my-site.com; 4 | charset utf-8; 5 | client_max_body_size 51M; 6 | 7 | location /static { 8 | alias /srv/http/my-site.com/src/mangadventure/static; 9 | } 10 | 11 | location /media { 12 | alias /srv/http/my-site.com/src/mangadventure/media; 13 | } 14 | 15 | location / { 16 | uwsgi_pass 127.0.0.1:25432; 17 | include uwsgi_params; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /groups/tests/test_sitemaps.py: -------------------------------------------------------------------------------- 1 | from groups.models import Group 2 | from groups.sitemaps import GroupSitemap 3 | 4 | from . import GroupsTestBase 5 | 6 | 7 | class TestSitemaps(GroupsTestBase): 8 | def setup_method(self): 9 | super().setup_method() 10 | self.group = Group.objects.create(name='Group') 11 | 12 | def test_groups(self): 13 | sitemap = GroupSitemap() 14 | assert list(sitemap.items()) == [self.group] 15 | assert self.group.sitemap_images == [] 16 | -------------------------------------------------------------------------------- /groups/tests/conftest.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from django.core.management import call_command 4 | 5 | from pytest import fixture 6 | 7 | 8 | @fixture(scope='class') 9 | def django_db_setup(django_db_setup, django_db_blocker): 10 | fixtures_dir = Path(__file__).resolve().parent / 'fixtures' 11 | user_fixture = fixtures_dir / 'users.json' 12 | with django_db_blocker.unblock(): 13 | call_command('flush', '--no-input') 14 | call_command('loaddata', str(user_fixture)) 15 | -------------------------------------------------------------------------------- /reader/tests/conftest.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from django.core.management import call_command 4 | 5 | from pytest import fixture 6 | 7 | 8 | @fixture(scope='class') 9 | def django_db_setup(django_db_setup, django_db_blocker): 10 | fixtures_dir = Path(__file__).resolve().parent / 'fixtures' 11 | user_fixture = fixtures_dir / 'users.json' 12 | with django_db_blocker.unblock(): 13 | call_command('flush', '--no-input') 14 | call_command('loaddata', str(user_fixture)) 15 | -------------------------------------------------------------------------------- /users/tests/test_tags.py: -------------------------------------------------------------------------------- 1 | from allauth.socialaccount.models import SocialApp 2 | 3 | from users.templatetags.user_tags import get_oauth_providers 4 | 5 | from . import UsersTestBase 6 | 7 | 8 | class TestAvailableSocialApps(UsersTestBase): 9 | def test_empty(self): 10 | assert not get_oauth_providers() 11 | 12 | def test_valid(self): 13 | SocialApp.objects.create( 14 | provider='discord', name='test', client_id='test' 15 | ) 16 | assert len(get_oauth_providers()) == 1 17 | -------------------------------------------------------------------------------- /groups/migrations/0003_alter_group_id.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations, models 2 | 3 | 4 | class Migration(migrations.Migration): 5 | dependencies = [ 6 | ('groups', '0002_managers'), 7 | ] 8 | 9 | operations = [ 10 | migrations.AlterField( 11 | model_name='group', 12 | name='id', 13 | field=models.SmallAutoField( 14 | auto_created=True, primary_key=True, 15 | serialize=False, verbose_name='ID' 16 | ), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /reader/migrations/0008_chapter_views.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations, models 2 | 3 | 4 | class Migration(migrations.Migration): 5 | dependencies = [('reader', '0007_series_licensed')] 6 | 7 | operations = [ 8 | migrations.AddField( 9 | model_name='chapter', 10 | name='views', 11 | field=models.PositiveIntegerField( 12 | db_index=True, default=0, editable=False, 13 | help_text='The total views of the chapter.' 14 | ), 15 | ), 16 | ] 17 | -------------------------------------------------------------------------------- /config/management/commands/clearcache.py: -------------------------------------------------------------------------------- 1 | """Clear cache command""" 2 | 3 | from django.core.cache import cache 4 | from django.core.management import BaseCommand 5 | 6 | 7 | class Command(BaseCommand): 8 | """Command used to clear the cache.""" 9 | help = 'Clear the cache.' 10 | 11 | def handle(self, *args: str, **options: str): 12 | """ 13 | Execute the command. 14 | 15 | :param args: The arguments of the command. 16 | :param options: The options of the command. 17 | """ 18 | cache.clear() 19 | -------------------------------------------------------------------------------- /config/management/commands/createsuperuser.py: -------------------------------------------------------------------------------- 1 | """Override the createsuperuser command.""" 2 | 3 | from django.contrib.auth.management.commands import createsuperuser 4 | from django.contrib.auth.models import User 5 | 6 | 7 | class Command(createsuperuser.Command): 8 | def handle(self, *args: str, **options: str): 9 | # HACK: disallow multiple superusers 10 | if User.objects.filter(is_superuser=True).exists(): 11 | self.stderr.write('A superuser already exists.') 12 | else: 13 | super().handle(*args, **options) 14 | -------------------------------------------------------------------------------- /MangAdventure/forms.py: -------------------------------------------------------------------------------- 1 | """Custom form fields.""" 2 | 3 | from django.forms import CharField, URLField 4 | 5 | from MangAdventure import validators 6 | 7 | 8 | class TwitterField(CharField): 9 | """A :class:`~django.forms.CharField` for Twitter usernames.""" 10 | default_validators = [validators.TwitterNameValidator()] 11 | 12 | 13 | class DiscordURLField(URLField): 14 | """A :class:`~django.forms.URLField` for Discord server URLs.""" 15 | default_validators = [validators.DiscordServerValidator()] 16 | 17 | 18 | __all__ = ['TwitterField', 'DiscordURLField'] 19 | -------------------------------------------------------------------------------- /static/styles/_mixins.scss: -------------------------------------------------------------------------------- 1 | @mixin size($width, $height: $width) { 2 | width: $width; 3 | height: $height; 4 | } 5 | 6 | @mixin truncate() { 7 | overflow: hidden; 8 | white-space: nowrap; 9 | text-overflow: ellipsis; 10 | } 11 | 12 | @mixin touch($selector: null) { 13 | &:hover#{$selector}, 14 | &:active#{$selector}, 15 | &:focus#{$selector} { 16 | @content; 17 | } 18 | } 19 | 20 | @mixin shadow($size, $color, $type: 'text') { 21 | #{$type}-shadow: -#{$size} 0 $size $color, 22 | 0 $size $size $color, $size 0 $size $color, 0 -#{$size} $size $color; 23 | } 24 | -------------------------------------------------------------------------------- /MangAdventure/tests/base.py: -------------------------------------------------------------------------------- 1 | from os import makedirs 2 | from shutil import rmtree 3 | 4 | from django.conf import settings 5 | from django.test import Client 6 | 7 | from pytest import mark 8 | 9 | 10 | @mark.django_db 11 | class MangadvTestBase: 12 | @classmethod 13 | def setup_class(cls): 14 | makedirs(settings.MEDIA_ROOT, exist_ok=True) 15 | 16 | def setup_method(self): 17 | self.client = Client() 18 | 19 | def teardown_method(self): 20 | pass 21 | 22 | @classmethod 23 | def teardown_class(cls): 24 | rmtree(settings.MEDIA_ROOT) 25 | -------------------------------------------------------------------------------- /users/migrations/0004_constraints.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations, models 2 | 3 | 4 | class Migration(migrations.Migration): 5 | dependencies = [('users', '0003_apikey')] 6 | 7 | operations = [ 8 | migrations.AlterUniqueTogether( 9 | name='bookmark', 10 | unique_together=set(), 11 | ), 12 | migrations.AddConstraint( 13 | model_name='bookmark', 14 | constraint=models.UniqueConstraint( 15 | fields=('series', 'user'), 16 | name='unique_bookmark' 17 | ), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /MangAdventure/templates/account/account_inactive.html: -------------------------------------------------------------------------------- 1 | {% extends 'layout.html' %} 2 | {% load account %} 3 | {% block title %} 4 | 5 | 6 | Inactive account · {{ config.NAME }} 7 | {% endblock %} 8 | {% block robots %} 9 | 10 | {% endblock %} 11 | {% block content %} 12 |

Inactive account

13 |
This account is inactive.
14 | {% endblock %} 15 | -------------------------------------------------------------------------------- /users/tests/conftest.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from django.core.management import call_command 4 | 5 | from pytest import fixture 6 | 7 | 8 | @fixture(scope='class') 9 | def django_db_setup(django_db_setup, django_db_blocker): 10 | fixtures_dir = Path(__file__).resolve().parent / 'fixtures' 11 | user_fixture = fixtures_dir / 'users.json' 12 | series_fixture = fixtures_dir / 'series.json' 13 | with django_db_blocker.unblock(): 14 | call_command('flush', '--no-input') 15 | call_command('loaddata', str(user_fixture)) 16 | call_command('loaddata', str(series_fixture)) 17 | -------------------------------------------------------------------------------- /config/tests/conftest.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from django.core.management import call_command 4 | 5 | from pytest import fixture 6 | 7 | 8 | @fixture(scope='class') 9 | def django_db_setup(django_db_setup, django_db_blocker): 10 | fixtures_dir = Path(__file__).resolve().parent / 'fixtures' 11 | user_fixture = fixtures_dir / 'users.json' 12 | page_fixture = fixtures_dir / 'info_pages.json' 13 | with django_db_blocker.unblock(): 14 | call_command('flush', '--no-input') 15 | call_command('loaddata', str(user_fixture)) 16 | call_command('loaddata', str(page_fixture)) 17 | -------------------------------------------------------------------------------- /groups/migrations/0004_constraints.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations, models 2 | 3 | 4 | class Migration(migrations.Migration): 5 | dependencies = [('groups', '0003_alter_group_id')] 6 | 7 | operations = [ 8 | migrations.AlterUniqueTogether( 9 | name='role', 10 | unique_together=set(), 11 | ), 12 | migrations.AddConstraint( 13 | model_name='role', 14 | constraint=models.UniqueConstraint( 15 | fields=('member', 'role', 'group'), 16 | name='unique_member_role' 17 | ), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /groups/sitemaps.py: -------------------------------------------------------------------------------- 1 | """Sitemaps for the groups app.""" 2 | 3 | from typing import Iterable 4 | 5 | from django.contrib.sitemaps import Sitemap 6 | 7 | from .models import Group 8 | 9 | 10 | class GroupSitemap(Sitemap): 11 | """Sitemap for groups.""" 12 | #: The priority of the sitemap. 13 | priority = 0.4 14 | 15 | def items(self) -> Iterable[Group]: 16 | """ 17 | Get an iterable of the sitemap's items. 18 | 19 | :return: An iterable of ``Group`` objects. 20 | """ 21 | return Group.objects.only('name', 'logo').order_by('name') 22 | 23 | 24 | __all__ = ['GroupSitemap'] 25 | -------------------------------------------------------------------------------- /MangAdventure/templates/error.html: -------------------------------------------------------------------------------- 1 | {% extends 'layout.html' %} 2 | {% block description %} 3 | 4 | 5 | {% endblock %} 6 | {% block robots %} 7 | 8 | {% endblock %} 9 | {% block title %} 10 | 11 | Error {{ error_status }} · {{ config.NAME }} 12 | {% endblock %} 13 | {% block content %} 14 |

{{ error_message|safe }}

15 | {% endblock %} 16 | -------------------------------------------------------------------------------- /docs/examples/apache.conf: -------------------------------------------------------------------------------- 1 | 2 | ServerName my-site.com 3 | ServerAlias www.my-site.com 4 | ServerAdmin you@email.com 5 | LimitRequestBody 51000000 6 | 7 | Alias /static /srv/http/my-site.com/src/mangadventure/static 8 | 9 | Require all granted 10 | 11 | 12 | Alias /media /srv/http/my-site.com/src/mangadventure/media 13 | 14 | Require all granted 15 | 16 | 17 | ProxyPass / uwsgi://127.0.0.1:25432/ 18 | 19 | -------------------------------------------------------------------------------- /users/__init__.py: -------------------------------------------------------------------------------- 1 | """The app that handles users.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import TYPE_CHECKING 6 | 7 | if TYPE_CHECKING: # pragma: no cover 8 | from django.contrib.auth.models import User 9 | 10 | 11 | def get_user_display(user: User) -> str: 12 | """ 13 | Display the name of the user. 14 | 15 | :param user: A ``User`` model instance. 16 | 17 | :return: The user's full name if available, otherwise the username. 18 | """ 19 | full_name = user.get_full_name() 20 | return full_name if len(full_name.strip()) else user.username 21 | 22 | 23 | __all__ = ['get_user_display'] 24 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 2 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.py] 12 | indent_size = 4 13 | max_line_length = 80 14 | quote_type = single 15 | 16 | [*.{ht,x}ml] 17 | quote_type = double 18 | 19 | [*.js] 20 | max_line_length = 80 21 | quote_type = single 22 | curly_bracket_next_line = false 23 | 24 | [*.json] 25 | curly_bracket_next_line = false 26 | 27 | [*.{s,}css] 28 | quote_type = single 29 | curly_bracket_next_line = false 30 | 31 | [*.rst] 32 | indent_size = 3 33 | 34 | [Makefile] 35 | indent_size = 4 36 | indent_style = tab 37 | -------------------------------------------------------------------------------- /config/tests/test_urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import reverse 2 | 3 | from pytest import importorskip 4 | 5 | from . import ConfigTestBase 6 | 7 | 8 | class TestInfoPage(ConfigTestBase): 9 | URL = reverse('info') 10 | 11 | def test_get(self): 12 | r = self.client.get(self.URL) 13 | assert r.status_code == 200 14 | assert 'About us' in str(r.content) 15 | 16 | def test_csp(self): 17 | importorskip('csp', reason='requires django-csp') 18 | r = self.client.get(self.URL) 19 | assert r.status_code == 200 20 | assert 'Content-Security-Policy' in r 21 | assert 'unsafe-inline' in r['Content-Security-Policy'] 22 | -------------------------------------------------------------------------------- /docs/modules/config.templatetags.rst: -------------------------------------------------------------------------------- 1 | config.templatetags package 2 | =========================== 3 | 4 | .. automodule:: config.templatetags 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | 9 | Submodules 10 | ---------- 11 | 12 | config.templatetags.custom\_tags module 13 | --------------------------------------- 14 | 15 | .. automodule:: config.templatetags.custom_tags 16 | :members: 17 | :undoc-members: 18 | :show-inheritance: 19 | 20 | config.templatetags.flatpage\_tags module 21 | ----------------------------------------- 22 | 23 | .. automodule:: config.templatetags.flatpage_tags 24 | :members: 25 | :undoc-members: 26 | :show-inheritance: 27 | -------------------------------------------------------------------------------- /MangAdventure/templates/socialaccount/authentication_error.html: -------------------------------------------------------------------------------- 1 | {% extends 'layout.html' %} 2 | {% block description %} 3 | <meta property="og:description" content="OAuth Login Failure"> 4 | <meta name="description" content="OAuth Login Failure"> 5 | {% endblock %} 6 | {% block robots %} 7 | <meta name="robots" content="noindex,nofollow,noarchive"> 8 | {% endblock %} 9 | {% block title %} 10 | <meta property="og:title" content="OAuth Login Failure"> 11 | <title>OAuth Login Failure · {{ config.NAME }} 12 | {% endblock %} 13 | {% block content %} 14 |

OAuth Login Failure

15 |
16 | {{ auth_error.exception }} 17 |
18 | {% endblock %} 19 | -------------------------------------------------------------------------------- /api/v2/negotiation.py: -------------------------------------------------------------------------------- 1 | """Content negotiation utilities.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import TYPE_CHECKING, List 6 | 7 | from rest_framework.negotiation import DefaultContentNegotiation 8 | 9 | if TYPE_CHECKING: # pragma: no cover 10 | from rest_framework.request import Request 11 | 12 | 13 | class OpenAPIContentNegotiation(DefaultContentNegotiation): 14 | """Class that fixes content negotiation for the OpenAPI schema.""" 15 | 16 | def get_accept_list(self, request: Request) -> List[str]: 17 | return super().get_accept_list(request) + [ 18 | 'application/vnd.oai.openapi+json' 19 | ] 20 | 21 | 22 | __all__ = ['OpenAPIContentNegotiation'] 23 | -------------------------------------------------------------------------------- /MangAdventure/templates/contribute.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "MangAdventure", 3 | "description": "A simple manga hosting CMS written in Django.", 4 | "repository": { 5 | "url": "https://github.com/mangadventure/MangAdventure.git", 6 | "license": "MIT", 7 | "type": "git" 8 | }, 9 | "participate": { 10 | "home": "https://github.com/mangadventure/MangAdventure", 11 | "docs": "https://mangadventure.readthedocs.io/en/latest/" 12 | }, 13 | "bugs": { 14 | "list": "https://github.com/mangadventure/MangAdventure/issues", 15 | "report": "https://github.com/mangadventure/MangAdventure/issues/new/choose" 16 | }, 17 | "keywords": ["python", "django", "scss", "html5", "vanillajs", "manga", "reader"] 18 | } 19 | -------------------------------------------------------------------------------- /docker/uwsgi.ini: -------------------------------------------------------------------------------- 1 | [uwsgi] 2 | master = true 3 | pcre-jit = true 4 | die-on-term = true 5 | thunder-lock = true 6 | threads = %k 7 | socket = :3000 8 | stats = :57475 9 | plugins = python3 10 | pythonpath = /web 11 | exec-pre-app = /web/manage.py migrate --no-input 12 | exec-pre-app = /web/manage.py createsuperuser --no-input 13 | wsgi-disable-file-wrapper = true 14 | wsgi-file = MangAdventure/wsgi.py 15 | safe-pidfile = /tmp/uwsgi.pid 16 | disable-logging = true 17 | log-4xx = false 18 | log-5xx = true 19 | log-date = %%Y-%%m-%%dT%%H:%%M:%%S%%z 20 | log-format-strftime = true 21 | log-format = %(addr) %(ftime) [%(method) %(uri) %(proto)] {%(status)} |%(rsize)| (%(referer)) "%(uagent)" 22 | env = LD_PRELOAD=/usr/lib/libmimalloc.so.2.0 23 | -------------------------------------------------------------------------------- /groups/templatetags/group_tags.py: -------------------------------------------------------------------------------- 1 | """Template tags of the groups app.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import TYPE_CHECKING 6 | 7 | from django.template.defaultfilters import register 8 | 9 | if TYPE_CHECKING: # pragma: no cover 10 | from groups.models import Group, Member 11 | 12 | 13 | @register.filter 14 | def group_roles(member: Member, group: Group) -> str: 15 | """ 16 | Get the roles of the member within the group. 17 | 18 | :param member: A ``Member`` model instance. 19 | :param group: A ``Group`` model instance. 20 | 21 | :return: A comma-separated list of roles. 22 | """ 23 | return member.get_roles(group) or 'N/A' 24 | 25 | 26 | __all__ = ['group_roles'] 27 | -------------------------------------------------------------------------------- /config/tests/fixtures/info_pages.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "model": "flatpages.flatpage", 4 | "pk": 1, 5 | "fields": { 6 | "url": "/info/", 7 | "title": "About us", 8 | "content": "", 9 | "enable_comments": false, 10 | "template_name": "", 11 | "registration_required": false, 12 | "sites": [ 13 | 1 14 | ] 15 | } 16 | }, 17 | { 18 | "model": "flatpages.flatpage", 19 | "pk": 2, 20 | "fields": { 21 | "url": "/privacy/", 22 | "title": "Privacy", 23 | "content": "", 24 | "enable_comments": false, 25 | "template_name": "", 26 | "registration_required": false, 27 | "sites": [ 28 | 1 29 | ] 30 | } 31 | } 32 | ] 33 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | 4 | ## Notes 5 | 6 | 7 | ## Checklist 8 | 9 | 10 | * [ ] I have read the contributor guidelines. 11 | * [ ] I have documented and/or commented my code. 12 | * [ ] I have updated the docs as necessary. 13 | * [ ] I have updated the tests as necessary. 14 | * [ ] I have verified that all tests pass locally. 15 | 16 | ## Your Environment 17 | 18 | 19 | * Operating System and version: 20 | * Python version: 21 | * Web server and version: 22 | * Browser type and version: 23 | -------------------------------------------------------------------------------- /MangAdventure/templates/account/password_reset_from_key_done.html: -------------------------------------------------------------------------------- 1 | {% extends 'layout.html' %} 2 | {% block title %} 3 | 4 | 5 | New Password · {{ config.NAME }} 6 | {% endblock %} 7 | {% block robots %} 8 | 9 | {% endblock %} 10 | {% block content %} 11 |

New Password

12 |
13 |
You have successfully reset your password.
14 |
Click here to sign in.
15 |
16 | {% endblock %} 17 | -------------------------------------------------------------------------------- /MangAdventure/templates/opensearch.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | {{ name }} 6 | Search {{ request.get_host }} 7 | {{ icon }} 8 | 9 | 10 | https://github.com/mangadventure 11 | {{ search }} 12 | {{ self }} 13 | 14 | -------------------------------------------------------------------------------- /MangAdventure/templates/account/password_reset_done.html: -------------------------------------------------------------------------------- 1 | {% extends 'layout.html' %} 2 | {% load account %} 3 | {% block title %} 4 | 5 | 6 | Password Reset · {{ config.NAME }} 7 | {% endblock %} 8 | {% block robots %} 9 | 10 | {% endblock %} 11 | {% block content %} 12 |

Password Reset

13 |
14 |
We have sent you an e-mail with a link to reset your password.
15 |
If you can't find the e-mail in your inbox, check your junk folder.
16 |
17 | {% endblock %} 18 | -------------------------------------------------------------------------------- /MangAdventure/templates/account/verification_sent.html: -------------------------------------------------------------------------------- 1 | {% extends 'layout.html' %} 2 | {% load account %} 3 | {% block title %} 4 | 5 | 6 | Activation Pending · {{ config.NAME }} 7 | {% endblock %} 8 | {% block robots %} 9 | 10 | {% endblock %} 11 | {% block content %} 12 |

Activation Pending

13 |
14 |
15 | We have sent you an activation link to the e-mail address you provided. 16 | Please click that link to complete your registration. 17 |
18 |
19 | {% endblock %} 20 | -------------------------------------------------------------------------------- /MangAdventure/templates/image-sitemap.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | {% spaceless %} 5 | {% for url in urlset %} 6 | 7 | {{ url.location }} 8 | {% if url.lastmod %}{{ url.lastmod|date:"Y-m-d" }}{% endif %} 9 | {% if url.changefreq %}{{ url.changefreq }}{% endif %} 10 | {% if url.priority %}{{ url.priority }}{% endif %} 11 | {% for img in url.item.sitemap_images %} 12 | {{ img }} 13 | {% endfor %} 14 | 15 | {% endfor %} 16 | {% endspaceless %} 17 | 18 | -------------------------------------------------------------------------------- /MangAdventure/tests/test_middleware.py: -------------------------------------------------------------------------------- 1 | from random import randint 2 | 3 | from django.test import Client 4 | from django.urls import reverse 5 | 6 | from MangAdventure.bad_bots import BOTS 7 | 8 | from .base import MangadvTestBase 9 | 10 | 11 | class TestBaseMiddleware(MangadvTestBase): 12 | def setup_method(self): 13 | super().setup_method() 14 | bot = BOTS[randint(0, len(BOTS) - 1)] 15 | self.bot = Client(HTTP_USER_AGENT=bot) 16 | 17 | def test_robots(self): 18 | r = self.bot.get(reverse('robots')) 19 | assert r.status_code == 200 20 | r = self.bot.get(reverse('index')) 21 | assert r.status_code == 403 22 | 23 | def test_early_data(self): 24 | r = self.client.post(reverse('index'), HTTP_EARLY_DATA='1') 25 | assert r.status_code == 425 26 | -------------------------------------------------------------------------------- /docs/modules/api.v1.rst: -------------------------------------------------------------------------------- 1 | api.v1 package 2 | ============== 3 | 4 | .. automodule:: api.v1 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | 9 | Submodules 10 | ---------- 11 | 12 | api.v1.apps module 13 | ------------------ 14 | 15 | .. automodule:: api.v1.apps 16 | :members: 17 | :undoc-members: 18 | :show-inheritance: 19 | 20 | api.v1.response module 21 | ---------------------- 22 | 23 | .. automodule:: api.v1.response 24 | :members: 25 | :undoc-members: 26 | :show-inheritance: 27 | 28 | api.v1.urls module 29 | ------------------ 30 | 31 | .. automodule:: api.v1.urls 32 | :members: 33 | :undoc-members: 34 | :show-inheritance: 35 | 36 | api.v1.views module 37 | ------------------- 38 | 39 | .. automodule:: api.v1.views 40 | :members: 41 | :undoc-members: 42 | :show-inheritance: 43 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.yml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://json.schemastore.org/github-issue-forms.json 2 | name: ❓ Question 3 | labels: ["Type: Question"] 4 | description: Do you have a question regarding this project? 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: |- 9 | Please read the [documentation][] before submitting your question. 10 | 11 | [documentation]: https://mangadventure.readthedocs.io/en/latest/ 12 | - type: textarea 13 | id: question 14 | attributes: 15 | label: Question 16 | description: Ask your question here. 17 | validations: {required: true} 18 | - type: input 19 | id: version 20 | attributes: 21 | label: MangAdventure Version 22 | description: Which version does your question concern? 23 | validations: {required: false} 24 | -------------------------------------------------------------------------------- /users/templates/delete.html: -------------------------------------------------------------------------------- 1 | {% extends 'layout.html' %} 2 | {% load account custom_tags %} 3 | {% block robots %} 4 | 5 | {% endblock %} 6 | {% with user=user_display %} 7 | {% block title %} 8 | 9 | 10 | Delete ~ {{ config.NAME }} 11 | {% endblock %} 12 | {% endwith %} 13 | {% block content %} 14 |

Delete Account

15 |
16 |
Are you sure you want to delete your account?
17 |
This will remove all associated data.
18 |
19 | 20 |
21 |
22 | {% endblock %} 23 | -------------------------------------------------------------------------------- /reader/migrations/0006_file_limits.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations, models 2 | 3 | from MangAdventure import validators 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [('reader', '0005_managers')] 8 | 9 | operations = [ 10 | migrations.AlterField( 11 | model_name='chapter', 12 | name='file', 13 | field=models.FileField( 14 | blank=True, help_text=( 15 | 'Upload a zip or cbz file containing the ' 16 | 'chapter pages. Its size cannot exceed 100 MBs ' 17 | 'and it must not contain more than 1 subfolder.' 18 | ), upload_to='', validators=( 19 | validators.FileSizeValidator(100), 20 | validators.zipfile_validator 21 | ), max_length=255 22 | ) 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /reader/migrations/0005_managers.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.db import migrations, models 3 | 4 | 5 | class Migration(migrations.Migration): 6 | dependencies = [ 7 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 8 | ('reader', '0004_aliases'), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name='series', 14 | name='manager', 15 | field=models.ForeignKey( 16 | help_text='The person who manages this series.', 17 | blank=False, null=True, on_delete=models.SET_NULL, 18 | to=settings.AUTH_USER_MODEL, limit_choices_to=models.Q( 19 | ('is_superuser', True), 20 | ('groups__name', 'Scanlator'), 21 | _connector='OR' 22 | ), 23 | ), 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /MangAdventure/tests/test_forms.py: -------------------------------------------------------------------------------- 1 | from django.forms import Form 2 | 3 | from MangAdventure.forms import DiscordURLField, TwitterField 4 | 5 | 6 | class FormTest(Form): 7 | twitter = TwitterField(required=False) 8 | discord = DiscordURLField(required=False) 9 | 10 | 11 | def test_twitter_valid(): 12 | form = FormTest(data={'twitter': 'name123'}) 13 | assert form.is_valid() 14 | 15 | 16 | def test_twitter_invalid(): 17 | form = FormTest(data={'twitter': '@Test123'}) 18 | assert not form.is_valid() 19 | 20 | 21 | def test_discord_valid(): 22 | form = FormTest(data={'discord': 'https://discord.gg/abc123'}) 23 | assert form.is_valid() 24 | form = FormTest(data={'discord': 'https://discord.me/abc123'}) 25 | assert form.is_valid() 26 | 27 | 28 | def test_discord_invalid(): 29 | form = FormTest(data={'discord': 'https://other.eu/test'}) 30 | assert not form.is_valid() 31 | -------------------------------------------------------------------------------- /static/scripts/bookmark.js: -------------------------------------------------------------------------------- 1 | (function(buttons) { 2 | buttons.forEach(btn => { 3 | btn.addEventListener('click', () => { 4 | const xhr = new XMLHttpRequest(); 5 | const data = new FormData(); 6 | data.append('series', btn.dataset.series); 7 | xhr.open('POST', btn.dataset.target, true); 8 | xhr.onload = function() { 9 | switch (xhr.status) { 10 | case 201: 11 | if ('umami' in window) 12 | window.umami.track('Bookmark', {url: location.href}); 13 | btn.className = 'mi mi-bookmark bookmark-btn'; 14 | break; 15 | case 204: 16 | btn.className = 'mi mi-bookmark-o bookmark-btn'; 17 | break; 18 | default: 19 | console.error(xhr.statusText); 20 | } 21 | }; 22 | xhr.send(data); 23 | }); 24 | }); 25 | })(document.querySelectorAll('.bookmark-btn')); 26 | -------------------------------------------------------------------------------- /api/tests/conftest.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from django.core.management import call_command 4 | 5 | from pytest import fixture 6 | 7 | 8 | @fixture(scope='class') 9 | def django_db_setup(django_db_setup, django_db_blocker): 10 | fixtures_dir = Path(__file__).resolve().parent / 'fixtures' 11 | series_fixture = fixtures_dir / 'series.json' 12 | chapters_fixture = fixtures_dir / 'chapters.json' 13 | authors_artists_fixture = fixtures_dir / 'authors_artists.json' 14 | groups_fixture = fixtures_dir / 'groups.json' 15 | with django_db_blocker.unblock(): 16 | call_command('flush', '--no-input') 17 | call_command('loaddata', 'categories.xml') 18 | call_command('loaddata', str(authors_artists_fixture)) 19 | call_command('loaddata', str(groups_fixture)) 20 | call_command('loaddata', str(series_fixture)) 21 | call_command('loaddata', str(chapters_fixture)) 22 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:3.18 2 | 3 | WORKDIR /web 4 | 5 | COPY . . 6 | 7 | # hadolint ignore=DL3018 8 | RUN ([ -f .env ] || mv .env.example .env) \ 9 | && mkdir -p .well-known logs media \ 10 | && touch static/styles/_variables.scss \ 11 | && mv docker/uwsgi.ini /etc/uwsgi.ini \ 12 | && apk add --no-cache \ 13 | mimalloc2 \ 14 | python3 \ 15 | py3-pip \ 16 | py3-psycopg2 \ 17 | py3-redis \ 18 | py3-wheel \ 19 | uwsgi-python3 \ 20 | && pip install --no-cache-dir -e '.[csp,sentry]' \ 21 | && ./manage.py collectstatic --no-input \ 22 | && chown -R uwsgi:uwsgi /web \ 23 | && apk del py3-pip py3-wheel \ 24 | && rm -rf docker 25 | 26 | VOLUME /web/logs \ 27 | /web/media \ 28 | /web/.well-known 29 | 30 | EXPOSE 3000 57475 31 | 32 | STOPSIGNAL SIGINT 33 | 34 | CMD ["uwsgi", "--ini=/etc/uwsgi.ini", "--uid=uwsgi", "--gid=uwsgi"] 35 | -------------------------------------------------------------------------------- /MangAdventure/tests/test_validators.py: -------------------------------------------------------------------------------- 1 | from django.forms import ValidationError 2 | 3 | from pytest import raises 4 | 5 | from MangAdventure.validators import ( 6 | DiscordNameValidator, RedditNameValidator, TwitterNameValidator 7 | ) 8 | 9 | 10 | def test_discord_name(): 11 | validate = DiscordNameValidator() 12 | validate('Epic_user-123#8910') 13 | with raises(ValidationError): 14 | validate('User') 15 | with raises(ValidationError): 16 | validate('User8910') 17 | 18 | 19 | def test_reddit_name(): 20 | validate = RedditNameValidator() 21 | validate('/u/epicuser_1234') 22 | validate('/r/epicsub_1234') 23 | validate('epicuser_1234') 24 | with raises(ValidationError): 25 | validate('/u/epic-user_1234') 26 | 27 | 28 | def test_twitter_name(): 29 | validate = TwitterNameValidator() 30 | validate('Epic_user-1234') 31 | with raises(ValidationError): 32 | validate('@Epic_user-1234') 33 | -------------------------------------------------------------------------------- /config/migrations/0002_scanlator_permissions.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations 2 | 3 | 4 | def add_scanlator_group(apps, schema_editor): 5 | group = apps.get_model('auth', 'Group') 6 | permission = apps.get_model('auth', 'Permission') 7 | scanlator = group.objects.create(name='Scanlator') 8 | scanlator.permissions.set(permission.objects.filter( 9 | content_type__app_label__in=('reader', 'groups') 10 | )) 11 | scanlator.save() 12 | 13 | 14 | def remove_scanlator_group(apps, schema_editor): 15 | apps.get_model('auth', 'Group').objects.get(name='Scanlator').delete() 16 | 17 | 18 | class Migration(migrations.Migration): 19 | dependencies = [ 20 | ('config', '0001_initial'), 21 | ('auth', '0001_initial'), 22 | ('reader', '0004_aliases'), 23 | ('groups', '0001_squashed'), 24 | ] 25 | 26 | operations = [ 27 | migrations.RunPython(add_scanlator_group, remove_scanlator_group) 28 | ] 29 | -------------------------------------------------------------------------------- /MangAdventure/templates/manifest.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "start_url": "/", 3 | "lang": "{{ lang }}", 4 | "name": "{{ name }}", 5 | "description": "{{ description }}", 6 | "background_color": "{{ background }}", 7 | "theme_color": "{{ color }}", 8 | "display": "standalone", 9 | "orientation": "portrait-primary", 10 | "prefer_related_applications": false, 11 | "icons": [{ 12 | "src": "/media/icon-192x192.webp", 13 | "sizes": "192x192", 14 | "type": "image/webp", 15 | "purpose": "any maskable" 16 | }, { 17 | "src": "/media/icon-512x512.webp", 18 | "sizes": "512x512", 19 | "type": "image/webp", 20 | "purpose": "any maskable" 21 | }], 22 | "shortcuts": [ 23 | {"name": "Latest", "url": "/"}, 24 | {"name": "Library", "url": "/reader/"}, 25 | {"name": "Search", "url": "/search/"}, 26 | {"name": "Bookmarks", "url": "/user/bookmarks/"} 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /docs/api/includes/chapter.rst: -------------------------------------------------------------------------------- 1 | * **url** (*string*) - The URL of the chapter. 2 | * **title** (*string*) - The title of the chapter. 3 | * **full_title** (*string*) - The title as shown on the site. 4 | * **pages_list** (*array of string*) - A list of pages of the chapter. 5 | * **pages_root** (*array of string*) - The root URl of the pages. 6 | * **date** (*string*) - The date the chapter was published. 7 | * **final** (*boolean*) - Whether the chapter is the finale of the series. 8 | 9 | .. indented 10 | 11 | * **url** (*string*) - The URL of the chapter. 12 | * **title** (*string*) - The title of the chapter. 13 | * **full_title** (*string*) - The title as shown on the site. 14 | * **pages_list** (*array of string*) - A list of pages of the chapter. 15 | * **pages_root** (*array of string*) - The root URl of the pages. 16 | * **date** (*string*) - The date the chapter was published. 17 | * **final** (*boolean*) - Whether the chapter is the finale of the series. 18 | -------------------------------------------------------------------------------- /groups/urls.py: -------------------------------------------------------------------------------- 1 | """The URLconf of the groups app.""" 2 | 3 | from django.contrib.sitemaps.views import sitemap 4 | from django.urls import path 5 | from django.views.decorators.cache import cache_control 6 | 7 | from . import feeds, sitemaps, views 8 | 9 | #: The URL namespace of the groups app. 10 | app_name = 'groups' 11 | 12 | _sitemaps = { 13 | 'template_name': 'image-sitemap.xml', 14 | 'sitemaps': {'groups': sitemaps.GroupSitemap} 15 | } 16 | 17 | _sitemap = cache_control(max_age=86400, must_revalidate=True)(sitemap) 18 | 19 | #: The URL patterns of the groups app. 20 | urlpatterns = [ 21 | path('', views.all_groups, name='all_groups'), 22 | path('/', views.group, name='group'), 23 | path('.atom', feeds.GroupAtom(), name='group.atom'), 24 | path('.rss', feeds.GroupRSS(), name='group.rss'), 25 | path('
-sitemap.xml', _sitemap, _sitemaps, name='sitemap.xml'), 26 | ] 27 | 28 | __all__ = ['app_name', 'urlpatterns'] 29 | -------------------------------------------------------------------------------- /users/tests/fixtures/series.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "model": "reader.series", 4 | "pk": 1, 5 | "fields": { 6 | "title": "Test Series", 7 | "slug": "test-series", 8 | "description": "", 9 | "cover": "series/test-series/cover.png", 10 | "status": "OG", 11 | "created": "2019-12-30T13:58:18.645Z", 12 | "modified": "2019-12-30T13:58:18.645Z", 13 | "authors": [], 14 | "artists": [], 15 | "categories": [], 16 | "manager": 2 17 | } 18 | }, 19 | { 20 | "model": "reader.series", 21 | "pk": 2, 22 | "fields": { 23 | "title": "Test Series 2", 24 | "slug": "test-series-2", 25 | "description": "", 26 | "cover": "series/test-series/cover.png", 27 | "status": "OG", 28 | "created": "2019-12-30T13:58:18.645Z", 29 | "modified": "2019-12-30T13:58:18.645Z", 30 | "authors": [], 31 | "artists": [], 32 | "categories": [], 33 | "manager": null 34 | } 35 | } 36 | ] 37 | -------------------------------------------------------------------------------- /users/templatetags/user_tags.py: -------------------------------------------------------------------------------- 1 | """Template tags of the users app.""" 2 | 3 | from typing import Dict, List 4 | 5 | from django.core.cache import cache 6 | from django.template.defaultfilters import register 7 | 8 | from allauth.socialaccount.models import SocialApp 9 | from allauth.socialaccount.templatetags.socialaccount import ( 10 | provider_login_url as _provider_login_url 11 | ) 12 | 13 | 14 | @register.simple_tag 15 | def get_oauth_providers() -> List[Dict[str, str]]: 16 | """Get a list of available OAuth providers.""" 17 | if not (providers := cache.get('oauth.providers')): 18 | providers = list(SocialApp.objects.values('provider', 'name')) 19 | cache.add('oauth.providers', providers) 20 | return providers 21 | 22 | 23 | # :func:`allauth.socialaccount.templatetags.socialaccount.provider_login_url` 24 | provider_login_url = register.simple_tag( 25 | _provider_login_url, takes_context=True 26 | ) 27 | 28 | __all__ = ['get_oauth_providers', 'provider_login_url'] 29 | -------------------------------------------------------------------------------- /docs/modules/config.rst: -------------------------------------------------------------------------------- 1 | config package 2 | ============== 3 | 4 | .. automodule:: config 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | 9 | Subpackages 10 | ----------- 11 | 12 | .. toctree:: 13 | :maxdepth: 4 14 | 15 | config.management 16 | config.templatetags 17 | 18 | Submodules 19 | ---------- 20 | 21 | config.admin module 22 | ------------------- 23 | 24 | .. automodule:: config.admin 25 | :members: 26 | :undoc-members: 27 | :show-inheritance: 28 | 29 | config.apps module 30 | ------------------ 31 | 32 | .. automodule:: config.apps 33 | :members: 34 | :undoc-members: 35 | :show-inheritance: 36 | 37 | config.context\_processors module 38 | --------------------------------- 39 | 40 | .. automodule:: config.context_processors 41 | :members: 42 | :undoc-members: 43 | :show-inheritance: 44 | 45 | config.urls module 46 | ------------------ 47 | 48 | .. automodule:: config.urls 49 | :members: 50 | :undoc-members: 51 | :show-inheritance: 52 | -------------------------------------------------------------------------------- /config/templatetags/flatpage_tags.py: -------------------------------------------------------------------------------- 1 | """Template tags used by the flatpage template.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import TYPE_CHECKING 6 | 7 | from django.template.defaultfilters import register 8 | 9 | from MangAdventure.jsonld import breadcrumbs 10 | 11 | from .custom_tags import jsonld 12 | 13 | if TYPE_CHECKING: # pragma: no cover 14 | from django.contrib.flatpages.models import FlatPage 15 | from django.http import HttpRequest 16 | 17 | 18 | @register.filter 19 | def breadcrumbs_ld(request: HttpRequest, page: FlatPage) -> str: 20 | """ 21 | Create a JSON-LD ``