├── pages ├── api │ ├── __init__.py │ ├── newsfeed.py │ ├── reactions.py │ ├── relationship.py │ ├── progress.py │ ├── assets.py │ └── sequences.py ├── templatetags │ ├── __init__.py │ └── pages_tags.py ├── templates │ ├── pages │ │ ├── editables │ │ │ ├── index.html │ │ │ └── element.html │ │ ├── certificate.html │ │ ├── app │ │ │ ├── _news_feed.html │ │ │ └── sequences │ │ │ │ ├── index.html │ │ │ │ └── pageelement.html │ │ ├── index.html │ │ ├── element.html │ │ ├── _follow_vote.html │ │ └── _comments.html │ └── _paginator.html ├── __init__.py ├── urls │ ├── __init__.py │ ├── views │ │ ├── __init__.py │ │ ├── elements.py │ │ ├── editables.py │ │ └── sequences.py │ └── api │ │ ├── assets.py │ │ ├── noauth2.py │ │ ├── sequences.py │ │ ├── progress.py │ │ ├── noauth.py │ │ ├── __init__.py │ │ ├── readers.py │ │ └── editables.py ├── admin.py ├── signals.py ├── views │ ├── __init__.py │ ├── sequences.py │ └── elements.py ├── docs.py ├── utils.py ├── helpers.py ├── settings.py └── compat.py ├── testsite ├── __init__.py ├── .gitignore ├── views │ ├── __init__.py │ └── app.py ├── templatetags │ ├── __init__.py │ └── testsite_tags.py ├── etc │ ├── credentials │ └── gunicorn.conf ├── templates │ ├── registration │ │ └── login.html │ ├── index.html │ └── base.html ├── requirements-legacy.txt ├── package.json ├── wsgi.py ├── requirements.txt ├── urls │ └── __init__.py ├── settings.py ├── fixtures │ └── default-db.json └── static │ └── vendor │ └── jquery.ba-throttle-debounce.js ├── MANIFEST.in ├── .gitignore ├── mkdocs.yml ├── manage.py ├── docs ├── user-guide │ ├── getting-started.md │ ├── pages-edition.md │ └── pages-upload-media.md ├── index.md └── license.md ├── .readthedocs.yaml ├── LICENSE.txt ├── setup.py ├── README.md ├── pyproject.toml ├── Makefile └── changelog /pages/api/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /testsite/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /testsite/.gitignore: -------------------------------------------------------------------------------- 1 | media -------------------------------------------------------------------------------- /testsite/views/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pages/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /testsite/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md requirements.txt 2 | -------------------------------------------------------------------------------- /pages/templates/pages/editables/index.html: -------------------------------------------------------------------------------- 1 | {% extends "pages/index.html" %} 2 | -------------------------------------------------------------------------------- /testsite/etc/credentials: -------------------------------------------------------------------------------- 1 | # SECURITY WARNING: keep the secret key used in production secret! 2 | SECRET_KEY = "%(SECRET_KEY)s" 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *~ 3 | *.xcodeproj 4 | .DS_Store 5 | db.sqlite 6 | credentials 7 | site.conf 8 | gunicorn.conf 9 | htdocs/ 10 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Djaodjin-pages 2 | pages: 3 | - [index.md, Home] 4 | - [user-guide/getting-started.md, User guide] 5 | - [user-guide/pages-edition.md, User guide] 6 | - [user-guide/pages-upload-media.md, User guide] 7 | - [license.md, License] 8 | -------------------------------------------------------------------------------- /testsite/templates/registration/login.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | {{form.as_p}} 4 | 5 |
6 | -------------------------------------------------------------------------------- /pages/templates/_paginator.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | [[count]] 5 | 6 |
7 |
-------------------------------------------------------------------------------- /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", "testsite.settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /testsite/requirements-legacy.txt: -------------------------------------------------------------------------------- 1 | # djaodjin-pages 2 | bleach==2.0.0 3 | Django==1.11.29 4 | djangorestframework==3.9.4 5 | djaodjin-deployutils==0.10.6 6 | djaodjin-extended-templates==0.4.2 7 | mammoth==1.6.0 8 | markdownify==0.11.6 9 | Markdown==3.1.1 10 | python-dateutil==2.8.1 11 | requests==2.22.0 12 | 13 | # testsite-only 14 | coverage==4.0.3 15 | django-extensions==2.0.6 16 | gunicorn==19.7.1 17 | whitenoise==4.1.2 18 | WeasyPrint==0.42.3 19 | -------------------------------------------------------------------------------- /docs/user-guide/getting-started.md: -------------------------------------------------------------------------------- 1 | # Getting started 2 | 3 | After cloning the repository, create a virtualenv environment, install the prerequisites, create the database then run the testsite webapp. 4 | 5 | ``` 6 | $ virtualenv-2.7 _installTop_ 7 | $ source _installTop_/bin/activate 8 | $ pip install -r requirements.txt 9 | $ make initdb 10 | $ python manage.py runserver 11 | 12 | # Browse http://localhost:8000/ 13 | # Start edit live templates 14 | ``` -------------------------------------------------------------------------------- /pages/templates/pages/certificate.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Certificate of Completion 5 | 6 | 7 | {{ certificate.text|safe }} 8 |
9 |
Certificate of Completion
10 |
This is to certify that
11 |
{{ user.username }}
12 |
13 | has successfully completed the {{ sequence.title }} sequence. 14 |
15 |
Date: {{ completion_date|date:"F j, Y" }}
16 |
17 | 18 | -------------------------------------------------------------------------------- /pages/templates/pages/app/_news_feed.html: -------------------------------------------------------------------------------- 1 |

News Feed

2 | 3 | 4 |
5 |
6 |
7 |
    8 |
  • 9 | [[ item.title ]]
    10 | [[ item.descr ]] 11 |
  • 12 |
13 |
14 |
15 |
16 | No updates to show. Please check back again later! 17 |
18 |
19 |
-------------------------------------------------------------------------------- /testsite/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "djaodjin-pages", 3 | "version" : "0.6.10-dev", 4 | "description": "browser client code for djaodjin-pages", 5 | "dependencies": { 6 | "dropzone": "4.2.0", 7 | "font-awesome": "~4.4.0", 8 | "hallo": "https://github.com/bergie/hallo.git", 9 | "jquery": "~3.6.0", 10 | "jquery-ui": "1.13.2", 11 | "jquery.selection": "0.1.2", 12 | "markdown": "v0.5.0", 13 | "pagedown": "1.1.0", 14 | "rangy": "1.3.0", 15 | "textarea-autosize": "0.4.1", 16 | "vue": "~2.7.8" 17 | }, 18 | "resolutions": { 19 | "font-awesome": "4.4.0" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /testsite/etc/gunicorn.conf: -------------------------------------------------------------------------------- 1 | # Template to configure gunicorn 2 | 3 | proc_name="testsite" 4 | bind="127.0.0.1:8020" 5 | workers=3 6 | pidfile="%(RUN_DIR)s/testsite.pid" 7 | accesslog="-" 8 | loglevel="info" 9 | # There is a typo in the default access_log_format so we set it explicitely 10 | # With gunicorn >= 19.0 we need to use %({X-Forwarded-For}i)s instead 11 | # of %(h)s because gunicorn will set REMOTE_ADDR to "" (see github issue #797) 12 | # Last "-" in nginx.conf:log_format is for ``http_x_forwarded_for`` 13 | access_log_format='%(h)s %({Host}i)s %({User-Session}o)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s" "%({X-Forwarded-For}i)s"' 14 | -------------------------------------------------------------------------------- /pages/templates/pages/index.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |
5 |
6 | 7 |
8 |
9 |
10 | 11 |
12 | [[item.title]] 13 |
14 |
15 | 16 |
17 |
18 |
19 |
20 |
21 | {% endblock %} 22 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yaml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | # Set the version of Python and other tools you might need 9 | build: 10 | os: ubuntu-22.04 11 | tools: 12 | python: "3.11" 13 | 14 | # Build documentation in the docs/ directory with Sphinx 15 | sphinx: 16 | configuration: docs/conf.py 17 | 18 | # We recommend specifying your dependencies to enable reproducible builds: 19 | # https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html 20 | python: 21 | install: 22 | - requirements: testsite/requirements.txt 23 | -------------------------------------------------------------------------------- /pages/templates/pages/element.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |
5 | 6 |
7 |

[[item.title]]

8 |
9 |
10 |
11 | enter text here... 12 |
13 |
14 | {% include "pages/_follow_vote.html" %} 15 | {% include "pages/_comments.html" %} 16 |
17 |
18 |
19 | {% endblock %} 20 | -------------------------------------------------------------------------------- /pages/templates/pages/editables/element.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |
5 | 6 |
7 |

[[item.title]]

8 |
9 |
10 |
11 | enter text here... 12 |
13 |
14 | {% include "pages/_follow_vote.html" %} 15 | {% include "pages/_comments.html" %} 16 |
17 |
18 |
19 | {% endblock %} 20 | -------------------------------------------------------------------------------- /pages/templates/pages/_follow_vote.html: -------------------------------------------------------------------------------- 1 |
2 | 4 | 6 | 8 | 10 | [[nbFollowers]] followers 12 | [[nbUpVotes]] upvotes 14 |
15 | -------------------------------------------------------------------------------- /pages/templates/pages/_comments.html: -------------------------------------------------------------------------------- 1 |
2 |

[[comments.count]] Comments

3 | 4 | 5 | 9 | 10 | 11 |
6 | [[comment.user]]
7 | [[comment.created_at]] 8 |
[[comment.text]]
12 | {% if user.is_authenticated %} 13 |
15 | 16 | 17 | 18 |
19 | {% else %} 20 |

Please log in to leave a comment.

21 | {% endif %} 22 |
23 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Djaodjin-Pages. 2 | --- 3 | 4 | Djaodjin-pages is a Django Application that allows to edit template files 5 | through the web browser in a What-You-See-Is-What-You-Get (WYSIWYG) mode. 6 | 7 | **Major Features:** 8 | 9 | * [Live template edition](user-guide/pages-edition.md) 10 | * Markdown syntax 11 | * [Upload media (images and videos)](user-guide/pages-upload-media.md) 12 | 13 | **Requirements:** 14 | 15 | * Django>=1.7 16 | * Markdown>=2.4.1 17 | * beautifulsoup4>=4.3.2 18 | * djangorestframework==3.1.0 19 | * pillow>=2.5.3 20 | * pycrypto>=2.6.1 21 | 22 | **Static dependencies:** 23 | 24 | * [jquery.js](http://jquery.com/) 25 | * [dropzone.js](http://www.dropzonejs.com/) 26 | * [jquery-ui.js](http://jqueryui.com/) 27 | * [jquery.selection.js](http://madapaja.github.io/jquery.selection/) 28 | * [jquery.ui-contextmenu.js](https://github.com/mar10/jquery-ui-contextmenu) 29 | * [Markdown.Converter.js](https://github.com/ujifgc/pagedown) 30 | * [Markdown.Sanitizer.js](https://github.com/ujifgc/pagedown) 31 | 32 | -------------------------------------------------------------------------------- /testsite/templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |
5 |

DjaoDjin Pages

6 |
7 |

For users

8 | Browse practices 9 |
10 |

Content

11 | Boxes & enclosures 12 |
13 |
14 |

Sequences

15 | 20 |
21 |
22 |
23 |

For editors

24 | 27 |
28 | {% include "pages/app/_news_feed.html" %} 29 |
30 | {% endblock %} 31 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015, DjaoDjin inc. 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, 8 | this list of conditions and the following disclaimer. 9 | 2. Redistributions in binary form must reproduce the above copyright 10 | notice, this list of conditions and the following disclaimer in the 11 | documentation and/or other materials provided with the distribution. 12 | 13 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 14 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED 15 | TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 16 | PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR 17 | CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 18 | EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 19 | PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; 20 | OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, 21 | WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR 22 | OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF 23 | ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | -------------------------------------------------------------------------------- /docs/license.md: -------------------------------------------------------------------------------- 1 | # License 2 | --- 3 | 4 | Copyright (c) 2015, DjaoDjin inc. 5 | All rights reserved. 6 | 7 | Redistribution and use in source and binary forms, with or without 8 | modification, are permitted provided that the following conditions are met: 9 | 10 | 1. Redistributions of source code must retain the above copyright notice, 11 | this list of conditions and the following disclaimer. 12 | 2. Redistributions in binary form must reproduce the above copyright 13 | notice, this list of conditions and the following disclaimer in the 14 | documentation and/or other materials provided with the distribution. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 17 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED 18 | TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 19 | PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR 20 | CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 21 | EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 22 | PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; 23 | OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, 24 | WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR 25 | OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF 26 | ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 27 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023, DjaoDjin inc. 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, 8 | # this list of conditions and the following disclaimer. 9 | # 2. Redistributions in binary form must reproduce the above copyright 10 | # notice, this list of conditions and the following disclaimer in the 11 | # documentation and/or other materials provided with the distribution. 12 | # 13 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 14 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED 15 | # TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 16 | # PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR 17 | # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 18 | # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 19 | # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; 20 | # OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, 21 | # WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR 22 | # OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF 23 | # ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | from setuptools import setup 25 | 26 | setup() 27 | -------------------------------------------------------------------------------- /testsite/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for testsite 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/1.6/howto/deployment/wsgi/ 8 | """ 9 | import os, signal 10 | 11 | from django.core.wsgi import get_wsgi_application 12 | 13 | 14 | def save_coverage(*args): 15 | sys.stderr.write("saving coverage\n") 16 | cov.stop() 17 | cov.save() 18 | 19 | if os.getenv('DJANGO_COVERAGE'): 20 | import atexit, sys 21 | import coverage 22 | data_file=os.path.join(os.getenv('DJANGO_COVERAGE'), 23 | ".coverage.%d" % os.getpid()) 24 | cov = coverage.coverage(data_file=data_file) 25 | sys.stderr.write("start recording coverage in %s\n" % str(data_file)) 26 | cov.set_option("run:relative_files", True) 27 | cov.start() 28 | atexit.register(save_coverage) 29 | try: 30 | signal.signal(signal.SIGTERM, save_coverage) 31 | except ValueError as e: 32 | # trapping signals does not work with manage 33 | # trying to do so fails with 34 | # ValueError: signal only works in main thread 35 | pass 36 | 37 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testsite.settings") 38 | 39 | # This application object is used by any WSGI server configured to use this 40 | # file. This includes Django's development server, if the WSGI_APPLICATION 41 | # setting points here. 42 | #pylint: disable=invalid-name 43 | application = get_wsgi_application() 44 | -------------------------------------------------------------------------------- /pages/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2025, Djaodjin Inc. 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, 8 | # this list of conditions and the following disclaimer. 9 | # 2. Redistributions in binary form must reproduce the above copyright notice, 10 | # this list of conditions and the following disclaimer in the documentation 11 | # and/or other materials provided with the distribution. 12 | # 13 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 14 | # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, 15 | # THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 16 | # PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR 17 | # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 18 | # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 19 | # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; 20 | # OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, 21 | # WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR 22 | # OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF 23 | # ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | 25 | """ 26 | PEP 386-compliant version number for the pages django app. 27 | """ 28 | 29 | __version__ = '0.8.7' 30 | -------------------------------------------------------------------------------- /pages/urls/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024, Djaodjin Inc. 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, 8 | # this list of conditions and the following disclaimer. 9 | # 2. Redistributions in binary form must reproduce the above copyright notice, 10 | # this list of conditions and the following disclaimer in the documentation 11 | # and/or other materials provided with the distribution. 12 | # 13 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 14 | # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, 15 | # THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 16 | # PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR 17 | # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 18 | # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 19 | # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; 20 | # OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, 21 | # WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR 22 | # OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF 23 | # ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | 25 | from ..compat import include, path 26 | 27 | 28 | urlpatterns = [ 29 | path(r'api/', include('pages.urls.api')), 30 | path(r'', include('pages.urls.views')), 31 | ] 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | djaodjin-pages is a Django application that implements a Content Management 2 | System (CMS) for practices sharing. 3 | 4 | Major Features: 5 | 6 | - Hierachical structure of content elements 7 | - Text edition (optional: markdown syntax) 8 | - Media gallery (drag'n'drop in markdown or media placeholder) 9 | 10 | Development 11 | =========== 12 | 13 | After cloning the repository, create a virtualenv environment, install 14 | the prerequisites, create the database then run the testsite webapp. 15 | 16 |

17 |     $ python -m venv .venv
18 |     $ source .venv/bin/activate
19 |     $ pip install -r testsite/requirements.txt
20 | 
21 |     # Installs Javascript prerequisites to run in the browser
22 |     $ make vendor-assets-prerequisites
23 | 
24 |     # Create the testsite database
25 |     $ make initdb
26 | 
27 |     # Run the testsite server
28 |     $ python manage.py runserver
29 | 
30 |     # Browse http://localhost:8000/
31 | 
32 | 
33 | 34 | 35 | Release Notes 36 | ============= 37 | 38 | Tested with 39 | 40 | - **Python:** 3.7, **Django:** 3.2 (legacy) 41 | - **Python:** 3.10, **Django:** 4.2 ([LTS](https://www.djangoproject.com/download/)) 42 | - **Python:** 3.12, **Django:** 5.2 (latest) 43 | 44 | 0.8.7 45 | 46 | * updates extra/tags field through UI editor 47 | * adds missing dependency html5lib 48 | 49 | [previous release notes](changelog) 50 | 51 | Version 0.4.3 is the last version that contains the HTML templates 52 | online editor. This functionality was moved to [djaodjin-extended-templates](https://github.com/djaodjin/djaodjin-extended-templates/) 53 | as of version 0.5.0. 54 | -------------------------------------------------------------------------------- /pages/admin.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2015, DjaoDjin inc. 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, 8 | # this list of conditions and the following disclaimer. 9 | # 2. Redistributions in binary form must reproduce the above copyright 10 | # notice, this list of conditions and the following disclaimer in the 11 | # documentation and/or other materials provided with the distribution. 12 | # 13 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 14 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED 15 | # TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 16 | # PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR 17 | # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 18 | # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 19 | # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; 20 | # OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, 21 | # WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR 22 | # OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF 23 | # ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | 25 | from django.contrib import admin 26 | # Register your models here. 27 | from .models import PageElement, RelationShip 28 | 29 | admin.site.register(RelationShip) 30 | admin.site.register(PageElement) 31 | -------------------------------------------------------------------------------- /pages/signals.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022, DjaoDjin inc. 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, 8 | # this list of conditions and the following disclaimer. 9 | # 2. Redistributions in binary form must reproduce the above copyright 10 | # notice, this list of conditions and the following disclaimer in the 11 | # documentation and/or other materials provided with the distribution. 12 | # 13 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 14 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED 15 | # TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 16 | # PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR 17 | # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 18 | # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 19 | # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; 20 | # OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, 21 | # WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR 22 | # OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF 23 | # ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | 25 | from django.dispatch import Signal 26 | 27 | #pylint: disable=invalid-name 28 | question_new = Signal( 29 | #providing_args=['question', 'request'] 30 | ) 31 | comment_was_posted = Signal( 32 | #providing_args=['comment', 'request'] 33 | ) 34 | -------------------------------------------------------------------------------- /pages/urls/views/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024, Djaodjin Inc. 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, 8 | # this list of conditions and the following disclaimer. 9 | # 2. Redistributions in binary form must reproduce the above copyright notice, 10 | # this list of conditions and the following disclaimer in the documentation 11 | # and/or other materials provided with the distribution. 12 | # 13 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 14 | # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, 15 | # THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 16 | # PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR 17 | # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 18 | # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 19 | # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; 20 | # OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, 21 | # WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR 22 | # OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF 23 | # ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | 25 | from ...compat import include, path 26 | 27 | urlpatterns = [ 28 | path('sequences/', include('pages.urls.views.sequences')), 29 | path('editables/', include('pages.urls.views.editables')), 30 | path('', include('pages.urls.views.elements')), 31 | ] 32 | -------------------------------------------------------------------------------- /pages/urls/views/elements.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023, Djaodjin Inc. 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, 8 | # this list of conditions and the following disclaimer. 9 | # 2. Redistributions in binary form must reproduce the above copyright notice, 10 | # this list of conditions and the following disclaimer in the documentation 11 | # and/or other materials provided with the distribution. 12 | # 13 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 14 | # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, 15 | # THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 16 | # PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR 17 | # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 18 | # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 19 | # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; 20 | # OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, 21 | # WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR 22 | # OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF 23 | # ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | 25 | from ...compat import path 26 | from ...views.elements import PageElementView 27 | 28 | urlpatterns = [ 29 | path('', 30 | PageElementView.as_view(), name='pages_element'), 31 | path('', 32 | PageElementView.as_view(), name='pages_index'), 33 | ] 34 | -------------------------------------------------------------------------------- /pages/urls/api/assets.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023, DjaoDjin inc. 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, 8 | # this list of conditions and the following disclaimer. 9 | # 2. Redistributions in binary form must reproduce the above copyright 10 | # notice, this list of conditions and the following disclaimer in the 11 | # documentation and/or other materials provided with the distribution. 12 | # 13 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 14 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED 15 | # TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 16 | # PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR 17 | # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 18 | # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 19 | # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; 20 | # OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, 21 | # WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR 22 | # OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF 23 | # ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | 25 | """API URLs for uploading assets""" 26 | 27 | from ...compat import path 28 | from ...api.assets import AssetAPIView, UploadAssetAPIView 29 | 30 | 31 | urlpatterns = [ 32 | path('assets/', 33 | AssetAPIView.as_view(), name='pages_api_asset'), 34 | path('assets', 35 | UploadAssetAPIView.as_view(), name='pages_api_upload_asset'), 36 | ] 37 | -------------------------------------------------------------------------------- /docs/user-guide/pages-edition.md: -------------------------------------------------------------------------------- 1 | #Djaodjin-pages: Edition 2 | 3 | *__Make your django TemplateView editable.__* 4 | 5 | --- 6 | 7 | ## Configuration 8 | --- 9 | 10 | Install djaodjin-pages by adding ```pages``` in your ```INSTALLED_APP```. 11 | 12 | ``` python 13 | INSTALLED_APP = ( 14 | ... 15 | 'pages', 16 | ... 17 | ) 18 | ``` 19 | 20 | If you want to allow edition of the same template by multiple account (User, Organization...) you need to configure djaodjin-pages ```ACCOUNT_MODEl```. 21 | 22 | In your urls.py: 23 | 24 | ``` python 25 | urlpatterns = patterns('', 26 | ... 27 | url(r'^(?P[\w-]+)/', include('pages.urls')), 28 | ... 29 | ) 30 | ``` 31 | 32 | In your settings.py 33 | 34 | ``` python 35 | PAGES = { 36 | 'PAGES_ACCOUNT_MODEL' : 'yoursite.ExampleAccount' 37 | 'PAGES_ACCOUNT_URL_KWARG' : 'account_slug' 38 | } 39 | ``` 40 | --- 41 | 42 | ## Usage 43 | --- 44 | 45 | ### Django settings 46 | 47 | To start with djaodjin-pages edition you need to replace your django TemplateView with Djaodjin-pages PageView. 48 | 49 | Replace : 50 | 51 | ``` python 52 | class HomeView(TemplateView): 53 | template_name = "index.html" 54 | ``` 55 | 56 | by: 57 | 58 | ``` python 59 | from pages.views import PageView 60 | 61 | class HomeView(PageView): 62 | template_name = "index.html" 63 | ``` 64 | 65 | ### Template settings 66 | 67 | All templates you want to get live edtion need some modifications. Djaodjin-pages run with [Djaodjin-editor](http://djaodjin.com) jquery plugin. 68 | 69 | All you need to add is a valid id on your editable section. 70 | 71 | ```html 72 |

Hello world!

73 | ``` 74 | 75 | Be careful to make ids unique. 76 | 77 | 78 | 79 | 80 | 81 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | # This pyproject.toml seems to work to build a new package 2 | # when `setuptools==67.6.1` is installed. 3 | [project] 4 | name = "djaodjin-pages" 5 | dynamic = ["version"] 6 | description = "Django application for practices-based content" 7 | readme = "README.md" 8 | requires-python = ">=3.7" 9 | license = {text = "BSD-2-Clause"} 10 | keywords = ["django", "cms"] 11 | authors = [ 12 | {name = "The DjaoDjin Team", email = "help@djaodjin.com"} 13 | ] 14 | maintainers = [ 15 | {name = "The DjaoDjin Team", email = "help@djaodjin.com"} 16 | ] 17 | classifiers = [ 18 | "Framework :: Django", 19 | "Environment :: Web Environment", 20 | "Programming Language :: Python", 21 | "License :: OSI Approved :: BSD License" 22 | ] 23 | dependencies = [ 24 | "beautifulsoup4>=4.13.4", 25 | "bleach>=6.0.0", 26 | "Django>=1.11", 27 | "djangorestframework>=3.3.1", 28 | "djaodjin-deployutils>=0.14.2", 29 | "djaodjin-extended-templates>=0.4.11", 30 | "html5lib>=1.1", 31 | "mammoth>=1.6.0", 32 | "markdownify>=0.11.6", 33 | "Markdown>=3.4.4", 34 | "python-dateutil>=2.8", 35 | "requests>=2.22" 36 | ] 37 | 38 | [project.urls] 39 | repository = "https://github.com/djaodjin/djaodjin-pages" 40 | documentation = "https://djaodjin-pages.readthedocs.io/" 41 | changelog = "https://github.com/djaodjin/djaodjin-pages/changelog" 42 | 43 | [build-system] 44 | requires = ["setuptools"] 45 | build-backend = "setuptools.build_meta" 46 | 47 | [tool.setuptools.packages.find] 48 | include = ["pages*"] 49 | 50 | [tool.setuptools.package-data] 51 | pages = [ 52 | 'static/css/*', 53 | 'static/js/*', 54 | 'static/vendor/css/*', 55 | 'static/vendor/js/*', 56 | 'templates/pages/*.html', 57 | 'templates/pages/editables/*.html', 58 | ] 59 | 60 | [tool.setuptools.dynamic] 61 | version = {attr = "pages.__version__"} 62 | -------------------------------------------------------------------------------- /pages/urls/views/editables.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022, Djaodjin Inc. 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, 8 | # this list of conditions and the following disclaimer. 9 | # 2. Redistributions in binary form must reproduce the above copyright notice, 10 | # this list of conditions and the following disclaimer in the documentation 11 | # and/or other materials provided with the distribution. 12 | # 13 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 14 | # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, 15 | # THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 16 | # PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR 17 | # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 18 | # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 19 | # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; 20 | # OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, 21 | # WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR 22 | # OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF 23 | # ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | 25 | from ... import settings 26 | from ...compat import re_path 27 | from ...views.elements import PageElementEditableView 28 | 29 | urlpatterns = [ 30 | re_path(r'^(?P%s)' % settings.PATH_RE, 31 | PageElementEditableView.as_view(), name='pages_editables_element'), 32 | re_path(r'^', 33 | PageElementEditableView.as_view(), name='pages_editables_index'), 34 | ] 35 | -------------------------------------------------------------------------------- /pages/urls/api/noauth2.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024, DjaoDjin inc. 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, 8 | # this list of conditions and the following disclaimer. 9 | # 2. Redistributions in binary form must reproduce the above copyright 10 | # notice, this list of conditions and the following disclaimer in the 11 | # documentation and/or other materials provided with the distribution. 12 | # 13 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 14 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED 15 | # TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 16 | # PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR 17 | # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 18 | # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 19 | # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; 20 | # OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, 21 | # WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR 22 | # OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF 23 | # ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | 25 | """ 26 | API URLs for readers who could be unauthenticated 27 | """ 28 | from ...compat import path 29 | from ...api.elements import PageElementAPIView, PageElementIndexAPIView 30 | 31 | 32 | urlpatterns = [ 33 | path('content/', PageElementAPIView.as_view(), 34 | name="api_content"), 35 | path('content', PageElementIndexAPIView.as_view(), 36 | name="api_content_index"), 37 | ] 38 | -------------------------------------------------------------------------------- /testsite/requirements.txt: -------------------------------------------------------------------------------- 1 | # Unified requirements file supporting py37-django3.2, py39-django4.2, 2 | # py312-django5.2 for djaodjin-pages 3 | 4 | beautifulsoup4==4.13.4 # extract first para of `PageElement.text` 5 | bleach==6.0.0 6 | Django==3.2.25 ; python_version < "3.9" 7 | Django==4.2.26 ; python_version >= "3.9" and python_version < "3.12" 8 | Django==5.2.8 ; python_version >= "3.12" 9 | djangorestframework==3.14.0 ; python_version < "3.9" 10 | djangorestframework==3.15.2 ; python_version >= "3.9" # Breaking 11 | # changes in 3.15.0 and 3.15.1 12 | # were reverted in 3.15.2. 13 | # Requires Django >=4.2 and 14 | # Python >=3.8. See release notes 15 | # for details: 16 | # https://github.com/encode/django-rest-framework/releases 17 | djaodjin-deployutils==0.14.2 18 | djaodjin-extended-templates==0.4.11 19 | html5lib==1.1 # required dep not pulled by bs4 20 | mammoth==1.6.0 # to upload .docx as HTML 21 | markdownify==0.11.6 # to upload .docx as markdown 22 | Markdown==3.4.4 # last version to support py3.7 23 | python-dateutil==2.8.1 24 | requests==2.31.0 25 | 26 | # testsite-only 27 | coverage==6.3.2 28 | django-debug-toolbar==3.8.1 ; python_version < "3.9" # 3.4+ requires Django>=3.2 29 | django-debug-toolbar==5.2.0 ; python_version >= "3.9" 30 | # 3.8.1 fails with Django5.2+ 31 | # 3.2.4 fails with SQLPanel is not scriptable 32 | # 2.2.1 is the last version for Django2.2 33 | django-extensions==3.2.1 34 | gunicorn==20.1.0 35 | whitenoise==5.1.0 36 | -------------------------------------------------------------------------------- /pages/views/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2017, DjaoDjin inc. 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, 8 | # this list of conditions and the following disclaimer. 9 | # 2. Redistributions in binary form must reproduce the above copyright 10 | # notice, this list of conditions and the following disclaimer in the 11 | # documentation and/or other materials provided with the distribution. 12 | # 13 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 14 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED 15 | # TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 16 | # PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR 17 | # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 18 | # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 19 | # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; 20 | # OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, 21 | # WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR 22 | # OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF 23 | # ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | 25 | from django.views.generic import RedirectView 26 | 27 | from ..utils import get_current_account 28 | 29 | class AccountRedirectView(RedirectView): 30 | """ 31 | Redirects to the URL containing the app. 32 | """ 33 | slug_url_kwarg = 'account' 34 | 35 | def get(self, request, *args, **kwargs): 36 | app = get_current_account() 37 | kwargs.update({self.slug_url_kwarg: app}) 38 | return super(AccountRedirectView, self).get(request, *args, **kwargs) 39 | -------------------------------------------------------------------------------- /testsite/views/app.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024, Djaodjin Inc. 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, 8 | # this list of conditions and the following disclaimer. 9 | # 2. Redistributions in binary form must reproduce the above copyright notice, 10 | # this list of conditions and the following disclaimer in the documentation 11 | # and/or other materials provided with the distribution. 12 | # 13 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 14 | # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, 15 | # THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 16 | # PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR 17 | # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 18 | # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 19 | # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; 20 | # OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, 21 | # WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR 22 | # OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF 23 | # ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | 25 | from django.views.generic import TemplateView 26 | 27 | from pages.compat import reverse 28 | from pages.helpers import update_context_urls 29 | 30 | 31 | class IndexView(TemplateView): 32 | template_name = 'index.html' 33 | 34 | def get_context_data(self, **kwargs): 35 | context = super(IndexView, self).get_context_data(**kwargs) 36 | update_context_urls(context, { 37 | 'api_news_feed': reverse('api_news_feed', args=(self.request.user,)) 38 | }) 39 | return context 40 | -------------------------------------------------------------------------------- /pages/urls/api/sequences.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024, DjaoDjin inc. 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, 8 | # this list of conditions and the following disclaimer. 9 | # 2. Redistributions in binary form must reproduce the above copyright 10 | # notice, this list of conditions and the following disclaimer in the 11 | # documentation and/or other materials provided with the distribution. 12 | # 13 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 14 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED 15 | # TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 16 | # PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR 17 | # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 18 | # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 19 | # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; 20 | # OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, 21 | # WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR 22 | # OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF 23 | # ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | 25 | """ 26 | API URLs for sequence objects 27 | """ 28 | 29 | from ...api.progress import (EnumeratedProgressResetAPIView, 30 | LiveEventAttendanceAPIView) 31 | from ...compat import path 32 | 33 | 34 | urlpatterns = [ 35 | path('//', 36 | LiveEventAttendanceAPIView.as_view(), 37 | name='api_mark_attendance'), 38 | path('/', 39 | EnumeratedProgressResetAPIView.as_view(), 40 | name='api_progress_reset'), 41 | ] 42 | -------------------------------------------------------------------------------- /pages/urls/api/progress.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024, DjaoDjin inc. 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, 8 | # this list of conditions and the following disclaimer. 9 | # 2. Redistributions in binary form must reproduce the above copyright 10 | # notice, this list of conditions and the following disclaimer in the 11 | # documentation and/or other materials provided with the distribution. 12 | # 13 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 14 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED 15 | # TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 16 | # PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR 17 | # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 18 | # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 19 | # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; 20 | # OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, 21 | # WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR 22 | # OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF 23 | # ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | 25 | """ 26 | API URLs for EnumeratedProgress objects 27 | """ 28 | 29 | from ...api.progress import (EnumeratedProgressListAPIView, 30 | EnumeratedProgressRetrieveAPIView) 31 | 32 | from ...compat import path 33 | 34 | urlpatterns = [ 35 | path('//', 36 | EnumeratedProgressRetrieveAPIView.as_view(), 37 | name='api_enumerated_progress_user_detail'), 38 | path('/', 39 | EnumeratedProgressListAPIView.as_view(), 40 | name='api_enumerated_progress_user_list'), 41 | ] 42 | -------------------------------------------------------------------------------- /pages/urls/api/noauth.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024, DjaoDjin inc. 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, 8 | # this list of conditions and the following disclaimer. 9 | # 2. Redistributions in binary form must reproduce the above copyright 10 | # notice, this list of conditions and the following disclaimer in the 11 | # documentation and/or other materials provided with the distribution. 12 | # 13 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 14 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED 15 | # TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 16 | # PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR 17 | # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 18 | # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 19 | # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; 20 | # OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, 21 | # WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR 22 | # OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF 23 | # ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | 25 | """ 26 | API URLs for readers who could be unauthenticated 27 | """ 28 | from ...compat import path 29 | from ...api.elements import PageElementSearchAPIView, PageElementDetailAPIView 30 | from ...api.sequences import SequencesIndexAPIView 31 | 32 | urlpatterns = [ 33 | path('search', PageElementSearchAPIView.as_view(), 34 | name='api_page_element_search'), 35 | path('sequences', SequencesIndexAPIView.as_view(), 36 | name='api_sequences_index'), 37 | path(r'detail/', PageElementDetailAPIView.as_view(), 38 | name='pages_api_pageelement') 39 | ] 40 | -------------------------------------------------------------------------------- /pages/urls/views/sequences.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023, Djaodjin Inc. 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, 8 | # this list of conditions and the following disclaimer. 9 | # 2. Redistributions in binary form must reproduce the above copyright notice, 10 | # this list of conditions and the following disclaimer in the documentation 11 | # and/or other materials provided with the distribution. 12 | # 13 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 14 | # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, 15 | # THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 16 | # PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR 17 | # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 18 | # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 19 | # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; 20 | # OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, 21 | # WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR 22 | # OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF 23 | # ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | 25 | from ...compat import path 26 | from ...views.sequences import (CertificateDownloadView, SequenceProgressView, 27 | SequencePageElementView) 28 | 29 | 30 | urlpatterns = [ 31 | path('//certificate/', 32 | CertificateDownloadView.as_view(), 33 | name='certificate_download'), 34 | path('///', 35 | SequencePageElementView.as_view(), 36 | name='sequence_page_element_view'), 37 | path('//', 38 | SequenceProgressView.as_view(), 39 | name='sequence_progress_view'), 40 | ] 41 | -------------------------------------------------------------------------------- /pages/templates/pages/app/sequences/index.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load pages_tags %} 3 | 4 | {% block content %} 5 |

{{ sequence.title }}

6 |
7 | 8 |
9 |
10 |
    11 | {% for element in elements %} 12 |
  • 13 | {% if element.is_live_event %} 14 | 15 | 16 | {{ element.title }}({{ element.rank }}) - 17 | Live Event 18 | {% elif element.is_certificate %} 19 | 20 | 21 | {{ element.title }}({{ element.rank }}) - Certificate 22 | 23 | {% else %} 24 | 25 | {{ element.title }}({{ element.rank }}) - 26 | 27 | 28 | 33 | 34 | 35 | No progress yet 36 | 37 | {% endif %} 38 |
  • 39 | {% endfor %} 40 |
41 |
42 | {% include '_paginator.html' %} 43 |
44 |
45 |
46 | {% endblock %} 47 | -------------------------------------------------------------------------------- /pages/urls/api/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024, DjaoDjin inc. 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, 8 | # this list of conditions and the following disclaimer. 9 | # 2. Redistributions in binary form must reproduce the above copyright 10 | # notice, this list of conditions and the following disclaimer in the 11 | # documentation and/or other materials provided with the distribution. 12 | # 13 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 14 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED 15 | # TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 16 | # PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR 17 | # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 18 | # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 19 | # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; 20 | # OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, 21 | # WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR 22 | # OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF 23 | # ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | 25 | """ 26 | API URLs for the pages application 27 | """ 28 | 29 | from ...api.elements import PageElementAPIView, PageElementIndexAPIView 30 | from ...compat import include, path 31 | 32 | urlpatterns = [ 33 | path('editables/', include('pages.urls.api.editables')), 34 | path('attendance/', include('pages.urls.api.sequences')), 35 | path('content/', include('pages.urls.api.readers')), 36 | path('content/', include('pages.urls.api.noauth')), 37 | path('', include('pages.urls.api.noauth2')), # 'api/content' index 38 | path('progress/', include('pages.urls.api.progress')), 39 | path('', include('pages.urls.api.assets')) 40 | ] 41 | -------------------------------------------------------------------------------- /testsite/templatetags/testsite_tags.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021, DjaoDjin inc. 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, 8 | # this list of conditions and the following disclaimer. 9 | # 2. Redistributions in binary form must reproduce the above copyright 10 | # notice, this list of conditions and the following disclaimer in the 11 | # documentation and/or other materials provided with the distribution. 12 | # 13 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 14 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED 15 | # TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 16 | # PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR 17 | # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 18 | # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 19 | # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; 20 | # OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, 21 | # WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR 22 | # OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF 23 | # ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | 25 | import json 26 | 27 | from django import template 28 | from django.contrib.messages.api import get_messages 29 | from django.forms import BaseForm 30 | from django.utils.safestring import mark_safe 31 | import six 32 | 33 | register = template.Library() 34 | 35 | 36 | @register.filter() 37 | def messages(obj): 38 | """ 39 | Messages to be displayed to the current session. 40 | """ 41 | if isinstance(obj, BaseForm): 42 | return obj.non_field_errors() 43 | return get_messages(obj) 44 | 45 | 46 | @register.filter 47 | def to_json(value): 48 | if isinstance(value, six.string_types): 49 | return value 50 | return mark_safe(json.dumps(value)) 51 | -------------------------------------------------------------------------------- /pages/docs.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021, Djaodjin Inc. 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, 8 | # this list of conditions and the following disclaimer. 9 | # 2. Redistributions in binary form must reproduce the above copyright 10 | # notice, this list of conditions and the following disclaimer in the 11 | # documentation and/or other materials provided with the distribution. 12 | # 13 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 14 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED 15 | # TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 16 | # PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR 17 | # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 18 | # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 19 | # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; 20 | # OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, 21 | # WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR 22 | # OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF 23 | # ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | 25 | #pylint:disable=unused-argument,unused-import 26 | 27 | try: 28 | from drf_spectacular.utils import extend_schema, OpenApiResponse 29 | except ImportError: 30 | from functools import wraps 31 | from .compat import available_attrs 32 | 33 | def extend_schema(function=None, **kwargs): 34 | """ 35 | Dummy decorator when drf_spectacular is not present. 36 | """ 37 | def decorator(view_func): 38 | @wraps(view_func, assigned=available_attrs(view_func)) 39 | def _wrapped_view(request, *args, **kwargs): 40 | return view_func(request, *args, **kwargs) 41 | return _wrapped_view 42 | 43 | if function: 44 | return decorator(function) 45 | return decorator 46 | 47 | class OpenApiResponse(object): 48 | """ 49 | Dummy response object to document API. 50 | """ 51 | def __init__(self, *args, **kwargs): 52 | pass 53 | -------------------------------------------------------------------------------- /pages/templates/pages/app/sequences/pageelement.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |
5 | 6 | Back to {{ sequence.slug }} 7 | 8 | 9 |

{{ element.content.title }}

10 | {% if element.content.text %} 11 |
12 |

{{ element.content.text|safe }}

13 |
14 | {% endif %} 15 | 16 | 17 | {% if element.is_live_event %} 18 |

Live Event URL: {{ element.content.events.first.location }}

19 | 20 | {% elif element.is_certificate %} 21 |

This is a certificate. Download Certificate

22 | {% else %} 23 | {% if not progress %} 24 |

No progress yet!

25 |
26 | 30 |
31 | 32 |

Click the button to start tracking your progress.

33 |
34 |
35 |
36 | {% else %} 37 |
38 | 44 |
45 | [[ duration | formatDuration ]] 46 |
47 |
48 |
49 | {% endif %} 50 | {% endif %} 51 | 52 | {% if previous_element %} 53 | 54 | Previous 55 | 56 | {% endif %} 57 | 58 | {% if next_element %} 59 | 60 | Next 61 | 62 | {% endif %} 63 |
64 | {% endblock %} 65 | -------------------------------------------------------------------------------- /pages/urls/api/readers.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2025, DjaoDjin inc. 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, 8 | # this list of conditions and the following disclaimer. 9 | # 2. Redistributions in binary form must reproduce the above copyright 10 | # notice, this list of conditions and the following disclaimer in the 11 | # documentation and/or other materials provided with the distribution. 12 | # 13 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 14 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED 15 | # TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 16 | # PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR 17 | # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 18 | # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 19 | # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; 20 | # OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, 21 | # WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR 22 | # OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF 23 | # ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | 25 | """ 26 | API URLs for readers who must be authenticated 27 | """ 28 | from ...compat import path 29 | 30 | from ...api.newsfeed import NewsFeedListAPIView 31 | from ...api.reactions import (FollowAPIView, UnfollowAPIView, UpvoteAPIView, 32 | DownvoteAPIView, CommentListCreateAPIView) 33 | 34 | 35 | urlpatterns = [ 36 | # NewsFeed 37 | path('/newsfeed', 38 | NewsFeedListAPIView.as_view(), name='api_news_feed'), 39 | # Following 40 | path('follow/', 41 | FollowAPIView.as_view(), name='pages_api_follow'), 42 | path('unfollow/', 43 | UnfollowAPIView.as_view(), name='pages_api_unfollow'), 44 | # Votes 45 | path('upvote/', 46 | UpvoteAPIView.as_view(), name='pages_api_upvote'), 47 | path('downvote/', 48 | DownvoteAPIView.as_view(), name='pages_api_downvote'), 49 | # Comments 50 | path('comments/', 51 | CommentListCreateAPIView.as_view(), name='pages_api_comments'), 52 | ] 53 | -------------------------------------------------------------------------------- /testsite/templates/base.html: -------------------------------------------------------------------------------- 1 | {% load testsite_tags %} 2 | 3 | 4 | 5 | djaodjin-pages testsite 6 | {% block localheader %}{% endblock %} 7 | 8 | 9 | {% block menubar %} 10 |
11 | Home 12 | {% if request.user.is_authenticated %} 13 | | {{request.user.username}} 14 | | Sign Out 15 | {% else %} 16 | | Sign In 17 | {% endif %} 18 |
19 | {% endblock %} 20 |
21 |
22 | {% for message in request|messages %} 23 |
24 | 25 |
{{message|safe}}
26 |
27 | {% endfor %} 28 | {% if form %} 29 | {% for message in form|messages %} 30 |
31 | 32 |
{{message}}
33 |
34 | {% endfor %} 35 | {% endif %} 36 | 41 |
42 |
43 | {% block content %}{% endblock %} 44 | {% block bodyscripts %} 45 | 46 | 47 | 52 | 53 | 54 | {% block pages_scripts %}{% endblock %} 55 | 63 | {% endblock %} 64 | 65 | 66 | -------------------------------------------------------------------------------- /docs/user-guide/pages-upload-media.md: -------------------------------------------------------------------------------- 1 | #Djaodjin-pages: Upload media 2 | 3 | *__Add Medias in your page by a simple drag'n'drop__* 4 | 5 | --- 6 | 7 | ##Configuration 8 | ---- 9 | 10 | Djaodjin-pages offer two configuration type. 11 | 12 | 1. Basic: Upload media Django way (file saved and served by your server) 13 | 1. Amazon S3 Storage: Upload media to Amazon S3 (file saved and served by Amazon) 14 | 15 | ### Basic 16 | 17 | Update your settings.py to manage Media. 18 | 19 | PAGES = { 20 | ... 21 | 'MEDIA_ROOT' : '/media_path', 22 | 'MEDIA_PREFIX' : '/path/to/uploaded/media', 23 | ... 24 | } 25 | 26 | All uploaded media will be saved on ```/media_path/path/to/uploaded/media```. If the [ACCOUNT_MODEL](pages-edition.md#configuration), medias will be saved automatically per account so in ```/media_path/path/to/uploaded/media/account_slug``` 27 | 28 | ### Amazon S3 Storage 29 | 30 | This feature requires two more dependencies: boto3, and django-storages. So first: 31 | 32 | $ pip install boto3 django-storages 33 | 34 | Add configuration to your settings.py 35 | 36 | PAGES = { 37 | ... 38 | 'USE_S3' : True, 39 | 'AWS_ACCESS_KEY_ID' : '', 40 | 'AWS_SECRET_ACCESS_KEY' : '', 41 | ... 42 | } 43 | 44 | 45 | By default djaodjin-pages will save uploaded file in your server while the file is being uploaded to S3. If you don't want any file in you server turn ```NO_LOCAL_STORAGE```to True. In this case, Djaodjin-pages will create a image miniature / video sample to be uploaded quickly to S3. In both case, the upload to S3 is background job. 46 | 47 | 48 | PAGES = { 49 | ... 50 | 'NO_LOCAL_STORAGE': True, 51 | ... 52 | } 53 | 54 | If ```NO_LOCAL_STORAGE``` Djaodjin-pages need the [FFMEG](https://www.ffmpeg.org/) library to proccess video file (make sample) 55 | 56 | 57 | --- 58 | ##Usage 59 | --- 60 | 61 | By Using djaodjin-sidebar-gallery jquery plugin, you will be able to drag and drop media and use them into your HTML. 62 | 63 | You only need to add image/video tags with an id starting by ```djmedia_``` ex: ```id="djmedia-image-top"``` 64 | 65 | **Images** 66 | 67 | Generic placeholder image 68 | 69 | 70 | **Video** 71 | 72 | 73 | -------------------------------------------------------------------------------- /pages/utils.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022, DjaoDjin inc. 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, 8 | # this list of conditions and the following disclaimer. 9 | # 2. Redistributions in binary form must reproduce the above copyright 10 | # notice, this list of conditions and the following disclaimer in the 11 | # documentation and/or other materials provided with the distribution. 12 | # 13 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 14 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED 15 | # TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 16 | # PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR 17 | # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 18 | # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 19 | # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; 20 | # OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, 21 | # WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR 22 | # OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF 23 | # ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | 25 | import logging, random, string 26 | 27 | from django.apps import apps as django_apps 28 | from django.core.exceptions import ImproperlyConfigured 29 | from django.core.validators import RegexValidator 30 | from django.utils.module_loading import import_string 31 | 32 | from . import settings 33 | from .compat import gettext_lazy as _ 34 | 35 | 36 | LOGGER = logging.getLogger(__name__) 37 | 38 | 39 | def random_slug(): 40 | return ''.join( 41 | random.choice(string.ascii_lowercase + string.digits)\ 42 | for count in range(20)) 43 | 44 | 45 | validate_title = RegexValidator(#pylint: disable=invalid-name 46 | r'^[a-zA-Z0-9- ]+$', 47 | _("Enter a valid title consisting of letters, " 48 | "numbers, space, underscores or hyphens."), 49 | 'invalid' 50 | ) 51 | 52 | 53 | def get_account_model(): 54 | """ 55 | Returns the ``Account`` model that is active in this project. 56 | """ 57 | try: 58 | return django_apps.get_model(settings.ACCOUNT_MODEL) 59 | except ValueError: 60 | raise ImproperlyConfigured( 61 | "ACCOUNT_MODEL must be of the form 'app_label.model_name'") 62 | except LookupError: 63 | raise ImproperlyConfigured("ACCOUNT_MODEL refers to model '%s'"\ 64 | " that has not been installed" % settings.ACCOUNT_MODEL) 65 | 66 | 67 | def get_current_account(): 68 | """ 69 | Returns the default account for a site. 70 | """ 71 | account = None 72 | if settings.DEFAULT_ACCOUNT_CALLABLE: 73 | account = import_string(settings.DEFAULT_ACCOUNT_CALLABLE)() 74 | LOGGER.debug("get_current_account: '%s'", account) 75 | return account 76 | -------------------------------------------------------------------------------- /testsite/urls/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2025, Djaodjin Inc. 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, 8 | # this list of conditions and the following disclaimer. 9 | # 2. Redistributions in binary form must reproduce the above copyright notice, 10 | # this list of conditions and the following disclaimer in the documentation 11 | # and/or other materials provided with the distribution. 12 | # 13 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 14 | # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, 15 | # THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 16 | # PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR 17 | # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 18 | # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 19 | # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; 20 | # OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, 21 | # WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR 22 | # OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF 23 | # ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | 25 | from django.conf import settings 26 | from django.conf.urls.static import static 27 | from django.contrib.staticfiles.urls import staticfiles_urlpatterns 28 | from django.views.static import serve 29 | 30 | from pages.compat import include, path, re_path 31 | from pages.api.elements import PageElementIndexAPIView 32 | 33 | from ..views.app import IndexView 34 | 35 | if settings.DEBUG: 36 | import debug_toolbar 37 | urlpatterns = [ 38 | path('__debug__/', include(debug_toolbar.urls)), 39 | ] 40 | else: 41 | urlpatterns = [] 42 | 43 | 44 | urlpatterns += [re_path(r'(?Pfavicon.ico)', serve, 45 | kwargs={'document_root': settings.HTDOCS})] \ 46 | + staticfiles_urlpatterns() \ 47 | + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) 48 | 49 | urlpatterns += [ 50 | path('', include('django.contrib.auth.urls')), 51 | path('app/supplier-1/', IndexView.as_view()), 52 | path('app/energy-utility/', IndexView.as_view()), 53 | # Replaced 54 | # path('', include('pages.urls')), 55 | # by following to insert `account` into the path. 56 | path('api/editables//', include('pages.urls.api.editables')), 57 | path('api/attendance//', include('pages.urls.api.sequences')), 58 | path('api/progress/', include('pages.urls.api.progress')), 59 | path('api/content/', include('pages.urls.api.readers')), 60 | path('api/content/', include('pages.urls.api.noauth')), 61 | path('api/', include('pages.urls.api.noauth2')), 62 | path('api/', include('pages.urls.api.assets')), 63 | path('', IndexView.as_view()), 64 | path('', include('pages.urls.views')), 65 | ] 66 | -------------------------------------------------------------------------------- /pages/helpers.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022, Djaodjin Inc. 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, 8 | # this list of conditions and the following disclaimer. 9 | # 2. Redistributions in binary form must reproduce the above copyright notice, 10 | # this list of conditions and the following disclaimer in the documentation 11 | # and/or other materials provided with the distribution. 12 | # 13 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 14 | # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, 15 | # THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 16 | # PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR 17 | # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 18 | # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 19 | # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; 20 | # OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, 21 | # WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR 22 | # OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF 23 | # ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | 25 | import json 26 | 27 | from .compat import six 28 | 29 | 30 | class ContentCut(object): 31 | """ 32 | Visitor that cuts down a content tree whenever TAG_PAGEBREAK is encountered. 33 | """ 34 | TAG_PAGEBREAK = 'pagebreak' 35 | 36 | def __init__(self, tag=TAG_PAGEBREAK, depth=1): 37 | #pylint:disable=unused-argument 38 | self.match = tag 39 | 40 | def enter(self, tag): 41 | if tag and self.match: 42 | if isinstance(tag, dict): 43 | if tag.get(self.match, False): 44 | return False 45 | return self.match not in tag.get('tags', []) 46 | return self.match not in tag 47 | return True 48 | 49 | def leave(self, attrs, subtrees): 50 | #pylint:disable=unused-argument 51 | return True 52 | 53 | 54 | def get_extra(obj, attr_name, default=None): 55 | try: 56 | if isinstance(obj.extra, six.string_types): 57 | try: 58 | obj.extra = json.loads(obj.extra) 59 | except (TypeError, ValueError): 60 | return default 61 | extra = obj.extra 62 | except AttributeError: 63 | extra = obj.get('extra') 64 | return extra.get(attr_name, default) if extra else default 65 | 66 | 67 | def update_context_urls(context, urls): 68 | if 'urls' in context: 69 | for key, val in six.iteritems(urls): 70 | if key in context['urls']: 71 | if isinstance(val, dict): 72 | context['urls'][key].update(val) 73 | else: 74 | # Because organization_create url is added in this mixin 75 | # and in ``OrganizationRedirectView``. 76 | context['urls'][key] = val 77 | else: 78 | context['urls'].update({key: val}) 79 | else: 80 | context.update({'urls': urls}) 81 | return context 82 | -------------------------------------------------------------------------------- /pages/urls/api/editables.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024, DjaoDjin inc. 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, 8 | # this list of conditions and the following disclaimer. 9 | # 2. Redistributions in binary form must reproduce the above copyright 10 | # notice, this list of conditions and the following disclaimer in the 11 | # documentation and/or other materials provided with the distribution. 12 | # 13 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 14 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED 15 | # TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 16 | # PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR 17 | # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 18 | # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 19 | # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; 20 | # OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, 21 | # WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR 22 | # OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF 23 | # ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | """ 25 | API URLs for editing content 26 | """ 27 | 28 | from ... import settings 29 | from ...compat import path, re_path 30 | from ...api.elements import (ImportDocxView, PageElementEditableDetail, 31 | PageElementEditableListAPIView) 32 | from ...api.relationship import (PageElementAliasAPIView, 33 | PageElementMirrorAPIView, PageElementMoveAPIView) 34 | from ...api.sequences import (SequenceListCreateAPIView, 35 | SequenceRetrieveUpdateDestroyAPIView, 36 | RemoveElementFromSequenceAPIView, AddElementToSequenceAPIView) 37 | 38 | 39 | urlpatterns = [ 40 | path(r'sequences//elements/', 41 | RemoveElementFromSequenceAPIView.as_view(), 42 | name='api_remove_element_from_sequence'), 43 | path('sequences//elements', 44 | AddElementToSequenceAPIView.as_view(), 45 | name='api_add_element_to_sequence'), 46 | path('sequences/', 47 | SequenceRetrieveUpdateDestroyAPIView.as_view(), 48 | name='api_sequence_retrieve_update_destroy'), 49 | path('sequences', 50 | SequenceListCreateAPIView.as_view(), 51 | name='api_sequence_list_create'), 52 | 53 | # We need `re_path` here otherwise it requires to duplicate the URLs 54 | # when dealing with the path to the root of the element tree. 55 | re_path('^content/alias/(?P%s)$' % settings.PATH_RE, 56 | PageElementAliasAPIView.as_view(), name='pages_api_alias_node'), 57 | re_path('^content/attach/(?P%s)$' % settings.PATH_RE, 58 | PageElementMoveAPIView.as_view(), name='pages_api_move_node'), 59 | re_path('^content/mirror/(?P%s)$' % settings.PATH_RE, 60 | PageElementMirrorAPIView.as_view(), name='pages_api_mirror_node'), 61 | path('content//import', 62 | ImportDocxView.as_view(), name='import_docx'), 63 | path('content/', 64 | PageElementEditableDetail.as_view(), name='pages_api_edit_element'), 65 | path('content', PageElementEditableListAPIView.as_view(), 66 | name='pages_api_editables_index'), 67 | ] 68 | -------------------------------------------------------------------------------- /pages/templatetags/pages_tags.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024, Djaodjin Inc. 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, 8 | # this list of conditions and the following disclaimer. 9 | # 2. Redistributions in binary form must reproduce the above copyright notice, 10 | # this list of conditions and the following disclaimer in the documentation 11 | # and/or other materials provided with the distribution. 12 | # 13 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 14 | # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, 15 | # THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 16 | # PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR 17 | # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 18 | # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 19 | # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; 20 | # OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, 21 | # WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR 22 | # OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF 23 | # ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | 25 | import markdown 26 | from django import template 27 | from django.template.defaultfilters import stringfilter 28 | from django.utils.safestring import mark_safe 29 | 30 | from ..compat import six 31 | 32 | 33 | register = template.Library() 34 | 35 | 36 | @register.filter 37 | def get_relationships(element, tag=None): 38 | return element.get_relationships(tag).all() 39 | 40 | 41 | @register.filter(needs_autoescape=False) 42 | @stringfilter 43 | def md(text): #pylint: disable=invalid-name 44 | # XXX safe_mode is deprecated. Should we use bleach? As shown in example: 45 | # https://pythonhosted.org/Markdown/reference.html#markdown 46 | return mark_safe(markdown.markdown(text, enable_attributes=False)) 47 | 48 | 49 | @register.simple_tag 50 | def print_tree(tree, excludes=None): 51 | html = print_dict(tree, "
    ", None, excludes) + "
" 52 | if html == "
    ": 53 | html = "No file yet." 54 | return mark_safe(html) 55 | 56 | def print_dict(dictionary, html="", parent=None, excludes=None): 57 | for key, value in six.iteritems(dictionary): 58 | if value: 59 | if not excludes or (excludes and not key in excludes): 60 | html += "
  • \ 61 | %s/
  • " % key 63 | if parent: 64 | html += print_dict( 65 | value, "
      " % key, "%s/%s" %\ 67 | (parent, key), excludes) + "
    " 68 | else: 69 | html += print_dict( 70 | value, "
      " %\ 72 | key, key, excludes) + "
    " 73 | else: 74 | if parent: 75 | html += "
  • \ 76 | %s
  • " %\ 78 | (parent, key, key) 79 | else: 80 | html += "
  • \ 81 | %s
  • " %\ 83 | (key, key) 84 | return html 85 | 86 | @register.filter 87 | def get_item(dictionary, key): 88 | return dictionary.get(key) 89 | -------------------------------------------------------------------------------- /pages/api/newsfeed.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2025, DjaoDjin inc. 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, 8 | # this list of conditions and the following disclaimer. 9 | # 2. Redistributions in binary form must reproduce the above copyright notice, 10 | # this list of conditions and the following disclaimer in the documentation 11 | # and/or other materials provided with the distribution. 12 | # 13 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 14 | # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, 15 | # THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 16 | # PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR 17 | # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 18 | # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 19 | # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; 20 | # OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, 21 | # WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR 22 | # OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF 23 | # ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | from __future__ import unicode_literals 25 | 26 | from django.db.models import Subquery, OuterRef, F, Q, Count 27 | from rest_framework import generics 28 | 29 | from ..mixins import UserMixin 30 | from ..models import Follow, PageElement 31 | from ..serializers import UserNewsSerializer 32 | 33 | 34 | class NewsFeedListAPIView(UserMixin, generics.ListAPIView): 35 | """ 36 | Retrieves relevant news for a user 37 | 38 | **Tags**: content, user 39 | 40 | **Examples** 41 | 42 | .. code-block:: http 43 | 44 | GET /api/content/steve/newsfeed HTTP/1.1 45 | 46 | responds 47 | 48 | .. code-block:: json 49 | 50 | { 51 | "count": 1, 52 | "next": null, 53 | "previous": null, 54 | "results": [{ 55 | "path": "/metal/boxes-and-enclosures/production/\ 56 | energy-efficiency/process-heating/combustion/adjust-air-fuel-ratio", 57 | "text_updated_at": "2024-01-01T00:00:00Z", 58 | "last_read_at": "2023-12-01T00:00:00Z", 59 | "nb_comments_since_last_read": 5, 60 | "descr": "" 61 | }] 62 | } 63 | """ 64 | serializer_class = UserNewsSerializer 65 | 66 | @property 67 | def visibility(self): 68 | return None 69 | 70 | @property 71 | def owners(self): 72 | return None 73 | 74 | def get_updated_elements(self, start_at=None, ends_at=None): 75 | """ 76 | Returns `PageElement` accessible to a user, ordered by last update 77 | time, with a priority with the ones followed. 78 | """ 79 | queryset = PageElement.objects.filter_available( 80 | visibility=self.visibility, accounts=self.owners, 81 | start_at=start_at, ends_at=ends_at).exclude( 82 | Q(text__isnull=True) | Q(text="")).annotate( 83 | follow=Count('followers', 84 | filter=Q(followers__user=self.user)), 85 | # We cannot use `last_read_at=Max('followers__last_read_at', 86 | # filter=Q(followers__user=self.user))` here, otherwise Django 87 | # ORM is not able to create a valid SQL query for 88 | # `nb_comments_since_last_read`. 89 | last_read_at=Subquery( 90 | Follow.objects.filter( 91 | user=self.user, 92 | element=OuterRef('pk') 93 | ).values('last_read_at')[:1]), 94 | nb_comments_since_last_read=Count('comments', 95 | filter=Q(comments__created_at__gte=F('last_read_at'))) 96 | ).order_by('-follow', '-text_updated_at') 97 | return queryset 98 | 99 | 100 | def get_queryset(self): 101 | return self.get_updated_elements() 102 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # -*- Makefile -*- 2 | 3 | -include $(buildTop)/share/dws/prefix.mk 4 | 5 | srcDir ?= $(realpath .) 6 | installTop ?= $(if $(VIRTUAL_ENV),$(VIRTUAL_ENV),$(abspath $(srcDir))/.venv) 7 | binDir ?= $(installTop)/bin 8 | libDir ?= $(installTop)/lib 9 | CONFIG_DIR ?= $(installTop)/etc/testsite 10 | RUN_DIR ?= $(abspath $(srcDir)) 11 | 12 | installDirs ?= install -d 13 | installFiles := install -p -m 644 14 | NPM ?= npm 15 | PIP := pip 16 | PYTHON := python 17 | TWINE := twine 18 | 19 | 20 | ASSETS_DIR := $(srcDir)/htdocs/static 21 | DB_NAME ?= $(RUN_DIR)/db.sqlite 22 | 23 | MANAGE := TESTSITE_SETTINGS_LOCATION=$(CONFIG_DIR) RUN_DIR=$(RUN_DIR) $(PYTHON) manage.py 24 | 25 | # Django 1.7,1.8 sync tables without migrations by default while Django 1.9 26 | # requires a --run-syncdb argument. 27 | # Implementation Note: We have to wait for the config files to be installed 28 | # before running the manage.py command (else missing SECRECT_KEY). 29 | RUNSYNCDB = $(if $(findstring --run-syncdb,$(shell cd $(srcDir) && $(MANAGE) migrate --help 2>/dev/null)),--run-syncdb,) 30 | 31 | 32 | install:: 33 | cd $(srcDir) && $(PIP) install . 34 | 35 | 36 | install-conf:: $(DESTDIR)$(CONFIG_DIR)/credentials \ 37 | $(DESTDIR)$(CONFIG_DIR)/gunicorn.conf 38 | 39 | 40 | dist:: 41 | $(PYTHON) -m build 42 | $(TWINE) check dist/* 43 | $(TWINE) upload dist/* 44 | 45 | 46 | build-assets: vendor-assets-prerequisites 47 | 48 | 49 | clean:: clean-dbs 50 | [ ! -f $(srcDir)/package-lock.json ] || rm $(srcDir)/package-lock.json 51 | find $(srcDir) -name '__pycache__' -exec rm -rf {} + 52 | find $(srcDir) -name '*~' -exec rm -rf {} + 53 | 54 | clean-dbs: 55 | [ ! -f $(DB_NAME) ] || rm $(DB_NAME) 56 | 57 | 58 | doc: 59 | $(installDirs) build/docs 60 | cd $(srcDir) && sphinx-build -b html ./docs $(PWD)/build/docs 61 | 62 | 63 | initdb: clean-dbs 64 | $(installDirs) $(dir $(DB_NAME)) 65 | cd $(srcDir) && $(MANAGE) migrate $(RUNSYNCDB) --noinput 66 | cd $(srcDir) && $(MANAGE) loaddata \ 67 | testsite/fixtures/default-db.json 68 | 69 | 70 | vendor-assets-prerequisites: $(installTop)/.npm/djaodjin-pages-packages 71 | 72 | 73 | $(DESTDIR)$(CONFIG_DIR)/credentials: $(srcDir)/testsite/etc/credentials 74 | $(installDirs) $(dir $@) 75 | [ -f $@ ] || \ 76 | sed -e "s,\%(SECRET_KEY)s,`$(PYTHON) -c 'import sys ; from random import choice ; sys.stdout.write("".join([choice("abcdefghijklmnopqrstuvwxyz0123456789!@#$%^*-_=+") for i in range(50)]))'`," $< > $@ 77 | 78 | 79 | $(DESTDIR)$(CONFIG_DIR)/gunicorn.conf: $(srcDir)/testsite/etc/gunicorn.conf 80 | $(installDirs) $(dir $@) 81 | [ -f $@ ] || sed -e 's,%(RUN_DIR)s,$(RUN_DIR),' $< > $@ 82 | 83 | 84 | $(installTop)/.npm/djaodjin-pages-packages: $(srcDir)/testsite/package.json 85 | $(installFiles) $^ $(installTop) 86 | $(NPM) install --loglevel verbose --cache $(installTop)/.npm --prefix $(installTop) 87 | $(installDirs) $(ASSETS_DIR)/vendor $(ASSETS_DIR)/fonts 88 | $(installFiles) $(installTop)/node_modules/dropzone/dist/dropzone.css $(ASSETS_DIR)/vendor 89 | $(installFiles) $(installTop)/node_modules/dropzone/dist/dropzone.js $(ASSETS_DIR)/vendor 90 | $(installFiles) $(installTop)/node_modules/font-awesome/css/font-awesome.css $(ASSETS_DIR)/vendor 91 | $(installFiles) $(installTop)/node_modules/font-awesome/fonts/* $(ASSETS_DIR)/fonts 92 | $(installFiles) $(installTop)/node_modules/hallo/dist/hallo.js $(ASSETS_DIR)/vendor 93 | $(installFiles) $(installTop)/node_modules/jquery/dist/jquery.js $(ASSETS_DIR)/vendor 94 | $(installFiles) $(installTop)/node_modules/jquery.selection/dist/jquery.selection.js $(ASSETS_DIR)/vendor 95 | $(installFiles) $(installTop)/node_modules/pagedown/Markdown.Converter.js $(installTop)/node_modules/pagedown/Markdown.Sanitizer.js $(ASSETS_DIR)/vendor 96 | $(installFiles) $(installTop)/node_modules/rangy/lib/rangy-core.js $(ASSETS_DIR)/vendor 97 | $(installFiles) $(installTop)/node_modules/textarea-autosize/dist/jquery.textarea_autosize.js $(ASSETS_DIR)/vendor 98 | $(installFiles) $(installTop)/node_modules/vue/dist/vue.js $(ASSETS_DIR)/vendor 99 | touch $@ 100 | 101 | 102 | -include $(buildTop)/share/dws/suffix.mk 103 | 104 | .PHONY: all check dist doc install build-assets vendor-assets-prerequisites 105 | -------------------------------------------------------------------------------- /pages/settings.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2017, DjaoDjin inc. 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, 8 | # this list of conditions and the following disclaimer. 9 | # 2. Redistributions in binary form must reproduce the above copyright 10 | # notice, this list of conditions and the following disclaimer in the 11 | # documentation and/or other materials provided with the distribution. 12 | # 13 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 14 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED 15 | # TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 16 | # PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR 17 | # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 18 | # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 19 | # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; 20 | # OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, 21 | # WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR 22 | # OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF 23 | # ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | 25 | import os 26 | 27 | import bleach 28 | from django.conf import settings 29 | 30 | 31 | _SETTINGS = { 32 | 'ACCOUNT_LOOKUP_FIELD': 'username', 33 | 'ACCOUNT_MODEL': getattr(settings, 'AUTH_USER_MODEL', None), 34 | 'ACCOUNT_URL_KWARG': None, 35 | 'APP_NAME': getattr(settings, 'APP_NAME', 36 | os.path.basename(settings.BASE_DIR)), 37 | 'AUTH_USER_MODEL': getattr(settings, 'AUTH_USER_MODEL', None), 38 | 'AWS_SERVER_SIDE_ENCRYPTION': "AES256", 39 | 'AWS_STORAGE_BUCKET_NAME': 40 | getattr(settings, 'AWS_STORAGE_BUCKET_NAME', 41 | getattr(settings, 'APP_NAME', 42 | None)), 43 | 'BUCKET_NAME_FROM_FIELDS': ['bucket_name'], 44 | 'COMMENT_MAX_LENGTH': getattr(settings, 'COMMENT_MAX_LENGTH', 3000), 45 | 'DEFAULT_ACCOUNT_CALLABLE': '', 46 | 'DEFAULT_STORAGE_CALLABLE': '', 47 | 'EXTRA_FIELD': None, 48 | 'HTTP_REQUESTS_TIMEOUT': 3, 49 | 'MEDIA_PREFIX': "", 50 | 'MEDIA_ROOT': getattr(settings, 'MEDIA_ROOT'), 51 | 'MEDIA_URL': getattr(settings, 'MEDIA_URL'), 52 | 'PING_INTERVAL': getattr(settings, 'PING_INTERVAL', 10) 53 | } 54 | 55 | _SETTINGS.update(getattr(settings, 'PAGES', {})) 56 | 57 | ACCOUNT_LOOKUP_FIELD = _SETTINGS.get('ACCOUNT_LOOKUP_FIELD') 58 | ACCOUNT_MODEL = _SETTINGS.get('ACCOUNT_MODEL') 59 | ACCOUNT_URL_KWARG = _SETTINGS.get('ACCOUNT_URL_KWARG') 60 | APP_NAME = _SETTINGS.get('APP_NAME') 61 | AUTH_USER_MODEL = _SETTINGS.get('AUTH_USER_MODEL') 62 | AWS_SERVER_SIDE_ENCRYPTION = _SETTINGS.get('AWS_SERVER_SIDE_ENCRYPTION') 63 | AWS_STORAGE_BUCKET_NAME = _SETTINGS.get('AWS_STORAGE_BUCKET_NAME') 64 | BUCKET_NAME_FROM_FIELDS = _SETTINGS.get('BUCKET_NAME_FROM_FIELDS') 65 | COMMENT_MAX_LENGTH = _SETTINGS.get('COMMENT_MAX_LENGTH') 66 | DEFAULT_ACCOUNT_CALLABLE = _SETTINGS.get('DEFAULT_ACCOUNT_CALLABLE') 67 | DEFAULT_STORAGE_CALLABLE = _SETTINGS.get('DEFAULT_STORAGE_CALLABLE') 68 | EXTRA_FIELD = _SETTINGS.get('EXTRA_FIELD') 69 | HTTP_REQUESTS_TIMEOUT = _SETTINGS.get('HTTP_REQUESTS_TIMEOUT') 70 | MEDIA_PREFIX = _SETTINGS.get('MEDIA_PREFIX') 71 | MEDIA_ROOT = _SETTINGS.get('MEDIA_ROOT') 72 | MEDIA_URL = _SETTINGS.get('MEDIA_URL') 73 | PING_INTERVAL = _SETTINGS.get('PING_INTERVAL') 74 | 75 | LANGUAGE_CODE = getattr(settings, 'LANGUAGE_CODE') 76 | SLUG_RE = r'[a-zA-Z0-9_\-\+\.]+' 77 | PATH_RE = r'([a-zA-Z0-9\-]+/)*[a-zA-Z0-9\-]*' 78 | NON_EMPTY_PATH_RE = r'([a-zA-Z0-9\-]+/)*[a-zA-Z0-9\-]+' 79 | 80 | # Sanitizer settings 81 | _CUSTOM_ALLOWED_TAGS = [ 82 | 'blockquote', 83 | 'br', 84 | 'caption', 85 | 'div', 86 | 'dd', 87 | 'dl', 88 | 'dt', 89 | 'footer', 90 | 'h1', 91 | 'h2', 92 | 'h3', 93 | 'h4', 94 | 'h5', 95 | 'hr', 96 | 'label', 97 | 'img', 98 | 'input', 99 | 'p', 100 | 'pre', 101 | 'span', 102 | 'table', 103 | 'tbody', 104 | 'td', 105 | 'th', 106 | 'thead', 107 | 'tr', 108 | ] 109 | # `maxsplit` argument is only available on Python 3+ 110 | if int(bleach.__version__.split('.', maxsplit=1)[0]) >= 6: 111 | ALLOWED_TAGS = bleach.ALLOWED_TAGS | frozenset(_CUSTOM_ALLOWED_TAGS) 112 | else: 113 | ALLOWED_TAGS = bleach.ALLOWED_TAGS + _CUSTOM_ALLOWED_TAGS 114 | 115 | 116 | ALLOWED_ATTRIBUTES = bleach.ALLOWED_ATTRIBUTES 117 | ALLOWED_ATTRIBUTES.update({ 118 | 'a': ['href', 'target'], 119 | 'blockquote': ['class'], 120 | 'div': ['class'], 121 | 'footer': ['class'], 122 | 'img': ['class', 'src'], 123 | 'input': ['class', 'type', 'name', 'value'], 124 | 'table': ['class'], 125 | 'td': ['colspan', 'class'], 126 | 'th': ['colspan', 'class'], 127 | }) 128 | -------------------------------------------------------------------------------- /changelog: -------------------------------------------------------------------------------- 1 | 0.8.7 2 | 3 | * updates extra/tags field through UI editor 4 | * adds missing dependency html5lib 5 | 6 | -- Sebastien Mirolo Fri, 14 Sep 2025 14:00:00 -0700 7 | 8 | 0.8.6 9 | 10 | * sorts newsfeed most recent first 11 | * fixes using S3Storage with Django5 12 | 13 | -- Sebastien Mirolo Mon, 1 Sep 2025 10:00:00 -0700 14 | 15 | 0.8.5 16 | 17 | * adds start_at/ends_at to pick items in newsfeed 18 | 19 | -- Sebastien Mirolo Thu, 3 Jul 2025 10:05:00 -0700 20 | 21 | 0.8.4 22 | 23 | * updates newsfeed API to return short paragraphs ordered by last update 24 | * supports for Django 5.2 25 | 26 | -- Sebastien Mirolo Tue, 10 Jun 2025 09:30:00 -0700 27 | 28 | 0.8.3 29 | 30 | * rationalizes templates for read-only and editable elements 31 | 32 | -- Sebastien Mirolo Mon, 25 Nov 2024 16:10:00 -0700 33 | 34 | 0.8.2 35 | 36 | * updates API documentation 37 | * adds root in breadcrumbs 38 | 39 | -- Sebastien Mirolo Tue, 18 Jun 2024 02:25:00 -0700 40 | 41 | 0.8.1 42 | 43 | * loads commenter picture/name from profile API 44 | * makes API endpoint with or without account slug depending on URL pattern 45 | * handles updates to django-storages>=1.14 properly 46 | * newsfeed API for updates to PageElement a user follows (experimental) 47 | 48 | -- Sebastien Mirolo Thu, 11 Apr 2024 16:40:00 -0700 49 | 50 | 0.7.1 51 | 52 | * fixes URL routes regression introduced in version 0.7.0 53 | 54 | -- Sebastien Mirolo Thu, 25 Jan 2024 12:35:00 -0700 55 | 56 | 0.7.0 57 | 58 | * generates usable OpenAPI 3 schema 59 | * adds sequences and user progress through a sequence 60 | * imports content of PageElement as a .docx 61 | 62 | -- Sebastien Mirolo Thu, 18 Jan 2024 17:00:00 -0700 63 | 64 | 0.6.9 65 | 66 | * adds tags into practice description 67 | * compatibles with Bootstrap5 68 | * publishes distribution using pyproject.toml 69 | 70 | -- Sebastien Mirolo Sat, 12 Aug 2023 19:30:00 -0700 71 | 72 | 0.6.8 73 | 74 | * supports for bleach 6.0 released Jan 2023 introducing breaking changes 75 | 76 | -- Sebastien Mirolo Thu, 23 Feb 2023 17:25:00 -0700 77 | 78 | 0.6.7 79 | 80 | * redirects to uploaded document when assets APIis copy/pasted in URL bar 81 | 82 | -- Sebastien Mirolo Sun, 6 Nov 2022 14:33:00 -0700 83 | 84 | 0.6.6 85 | 86 | * optionally loads content directly through View instead of API 87 | * LOCALE keys can be up to 8 characters 88 | 89 | -- Sebastien Mirolo Thu, 8 Sep 2022 23:55:00 -0700 90 | 91 | 0.6.5 92 | 93 | * fixes uploaded document not found when going through S3 direct upload 94 | * compatibles with Django4.0 95 | 96 | -- Sebastien Mirolo Fri, 19 Aug 2022 20:06:00 -0700 97 | 98 | 0.6.4 99 | 100 | * whitelists HTML tags used by PageElement editor 101 | * compatibles with bleach 5+ 102 | 103 | -- Sebastien Mirolo Tue, 9 Aug 2022 10:15:00 -0700 104 | 105 | 0.6.3 106 | 107 | * uses content API for non-leafs elements in PageElementView 108 | * fixes computation of number of pages in pagination 109 | 110 | -- Sebastien Mirolo Wed, 5 Jul 2022 15:33:00 -0700 111 | 112 | 0.6.0 113 | 114 | * adds feature to upload documents 115 | * fixes accounts for obj.extra being None 116 | 117 | -- Sebastien Mirolo Wed, 8 Jun 2022 15:03:00 -0700 118 | 119 | 0.5.0 120 | 121 | * moves theme template editor to djaodjin-extended-templates 122 | where it fits best. 123 | * adds dynamic layout for page elements 124 | 125 | -- Sebastien Mirolo Sun, 1 May 2022 11:37:00 -0700 126 | 127 | 0.4.3 128 | 129 | * adds public parameter for profile pictures 130 | * sorts while flattening content tree 131 | * adds breadcrumbs in context 132 | 133 | -- Sebastien Mirolo Thu, 21 Apr 2022 22:39:00 -0700 134 | 135 | 0.4.2 136 | 137 | * clears cache for Jinja2>=2.9 138 | 139 | -- Sebastien Mirolo Sat, 2 Oct 2021 16:40:00 -0700 140 | 141 | 0.4.1 142 | 143 | * adds comment_was_posted signal 144 | * enables to customize html/css for pagination links 145 | * fitlers list of page elements by search criteria 146 | 147 | -- Sebastien Mirolo Thu, 30 Sep 2021 00:00:00 -0700 148 | 149 | 0.4.0 150 | 151 | * edits template wysiwyg-style directly 152 | * settles API for PageElements 153 | 154 | -- Sebastien Mirolo Sun, 18 Jul 2021 17:05:00 -0700 155 | 156 | 0.3.6 157 | 158 | * uses the correct active theme on create and read 159 | * switches default from S3BotoStorage to S3Boto3Storage 160 | 161 | -- Sebastien Mirolo Mon, 26 Apr 2021 23:40:00 -0700 162 | 163 | 0.3.5 164 | 165 | * chages DELETE media to take a query parameter instead of a body 166 | * moves all translated strings to the server 167 | * fixes installing theme with unicode characters in template files 168 | * fixes reactions APIs 169 | 170 | -- Sebastien Mirolo Wed, 24 Mar 2020 23:37:00 -0700 171 | 172 | 0.3.4 173 | 174 | * adds reactions in API for page element detail 175 | 176 | -- Sebastien Mirolo Thu, 26 Nov 2020 21:06:00 -0700 177 | 178 | 0.3.3 179 | 180 | * adds follow/upvote/comment reactions 181 | * insures API endpoint consistently ends without a trailing '/' 182 | 183 | -- Sebastien Mirolo Wed, 28 Oct 2020 14:33:00 -0700 184 | 185 | 0.3.0 186 | 187 | * adds an API end point to retrieve a content tree 188 | * adds an API end point to search for page elements 189 | * replaces api_page_elements by api_page_element_base 190 | 191 | -- Sebastien Mirolo Sun, 14 Jul 2020 13:59:00 -0700 192 | 193 | 0.2.9 194 | 195 | * compatibles with Django3 196 | * uses `detail` consistently to return user-friendly messages 197 | 198 | -- Sebastien Mirolo Sun, 7 Jun 2020 11:41:00 -0700 199 | 200 | 0.2.8 201 | 202 | * uses acl option to add ?public=1 203 | * updates API docs 204 | * adds blacklist for template files 205 | 206 | -- Sebastien Mirolo Thu, 5 Mar 2020 09:45:00 -0700 207 | -------------------------------------------------------------------------------- /pages/views/sequences.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024, Djaodjin Inc. 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, 8 | # this list of conditions and the following disclaimer. 9 | # 2. Redistributions in binary form must reproduce the above copyright notice, 10 | # this list of conditions and the following disclaimer in the documentation 11 | # and/or other materials provided with the distribution. 12 | # 13 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 14 | # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, 15 | # THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 16 | # PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR 17 | # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 18 | # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 19 | # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; 20 | # OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, 21 | # WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR 22 | # OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF 23 | # ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | import logging 25 | 26 | from deployutils.helpers import datetime_or_now 27 | from django.core.exceptions import PermissionDenied 28 | from django.views.generic import TemplateView, DetailView 29 | from extended_templates.backends.pdf import PdfTemplateResponse 30 | 31 | from .. import settings 32 | from ..compat import reverse 33 | from ..helpers import update_context_urls 34 | from ..mixins import EnumeratedProgressMixin, SequenceProgressMixin 35 | from ..models import EnumeratedElements 36 | 37 | LOGGER = logging.getLogger(__name__) 38 | 39 | 40 | class SequenceProgressView(SequenceProgressMixin, TemplateView): 41 | template_name = 'pages/app/sequences/index.html' 42 | 43 | def get_context_data(self, **kwargs): 44 | context = super(SequenceProgressView, self).get_context_data(**kwargs) 45 | 46 | queryset = self.get_queryset() 47 | decorated_queryset = self.decorate_queryset(queryset) 48 | 49 | context.update({ 50 | 'user': self.user, 51 | 'sequence': self.sequence, 52 | 'elements': decorated_queryset, 53 | }) 54 | 55 | context_urls = { 56 | 'api_enumerated_progress_user_list': reverse( 57 | 'api_enumerated_progress_user_list', 58 | args=(self.user, self.sequence,)), 59 | } 60 | 61 | if self.sequence.has_certificate: 62 | context_urls['certificate_download'] = reverse( 63 | 'certificate_download', 64 | args=(self.user, self.sequence,)) 65 | 66 | update_context_urls(context, context_urls) 67 | 68 | return context 69 | 70 | 71 | class SequencePageElementView(EnumeratedProgressMixin, TemplateView): 72 | 73 | template_name = 'pages/app/sequences/pageelement.html' 74 | 75 | def get_context_data(self, **kwargs): 76 | #pylint:disable=too-many-locals 77 | context = super(SequencePageElementView, 78 | self).get_context_data(**kwargs) 79 | 80 | element = self.progress.step 81 | previous_element = EnumeratedElements.objects.filter( 82 | sequence=element.sequence, rank__lt=element.rank).order_by( 83 | '-rank').first() 84 | next_element = EnumeratedElements.objects.filter( 85 | sequence=element.sequence, rank__gt=element.rank).order_by( 86 | 'rank').first() 87 | 88 | if previous_element: 89 | previous_element.url = reverse( 90 | 'sequence_page_element_view', 91 | args=(self.user, element.sequence, previous_element.rank)) 92 | if next_element: 93 | next_element.url = reverse( 94 | 'sequence_page_element_view', 95 | args=(self.user, element.sequence, next_element.rank)) 96 | viewing_duration_seconds = ( 97 | self.progress.viewing_duration.total_seconds() 98 | if self.progress.viewing_duration else 0) 99 | 100 | context.update({ 101 | 'sequence': element.sequence, 102 | 'element': element, 103 | 'previous_element': previous_element, 104 | 'next_element': next_element, 105 | 'ping_interval': settings.PING_INTERVAL, 106 | 'progress': self.progress, 107 | 'viewing_duration_seconds': viewing_duration_seconds, 108 | }) 109 | 110 | context_urls = { 111 | 'api_enumerated_progress_user_detail': reverse( 112 | 'api_enumerated_progress_user_detail', 113 | args=(self.user, element.sequence, element.rank)), 114 | 'sequence_progress_view': reverse( 115 | 'sequence_progress_view', 116 | args=(self.user, element.sequence,)), 117 | } 118 | 119 | if hasattr(element, 'is_live_event') and element.is_live_event: 120 | event = element.content.events.first() 121 | if event: 122 | context_urls['live_event_location'] = event.location 123 | 124 | if hasattr(element, 'is_certificate') and element.is_certificate: 125 | certificate = element.sequence.get_certificate 126 | if certificate: 127 | context_urls['certificate_download'] = reverse( 128 | 'certificate_download', args=(element.sequence,)) 129 | 130 | update_context_urls(context, context_urls) 131 | 132 | return context 133 | 134 | 135 | class CertificateDownloadView(SequenceProgressMixin, DetailView): 136 | 137 | template_name = 'pages/certificate.html' 138 | response_class = PdfTemplateResponse 139 | 140 | def get_context_data(self, **kwargs): 141 | context = super(CertificateDownloadView, self).get_context_data( 142 | **kwargs) 143 | has_completed_sequence = self.sequence_progress.is_completed 144 | context.update({ 145 | 'user': self.user, 146 | 'sequence': self.sequence_progress.sequence, 147 | 'has_certificate': self.sequence_progress.sequence.has_certificate, 148 | 'certificate': self.sequence_progress.sequence.get_certificate, 149 | 'has_completed_sequence': has_completed_sequence 150 | }) 151 | 152 | if has_completed_sequence: 153 | completion_date = datetime_or_now( 154 | self.sequence_progress.completion_date) 155 | if not self.sequence_progress.completion_date: 156 | self.sequence_progress.completion_date = completion_date 157 | self.sequence_progress.save() 158 | context['completion_date'] = completion_date 159 | 160 | return context 161 | 162 | def get(self, request, *args, **kwargs): 163 | if (self.sequence_progress.has_certificate and 164 | not self.sequence_progress.is_completed): 165 | raise PermissionDenied("Certificate is not available for download"\ 166 | " until you complete all elements.") 167 | return super(CertificateDownloadView, self).get( 168 | request, *args, **kwargs) 169 | -------------------------------------------------------------------------------- /pages/compat.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022, DjaoDjin inc. 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, 8 | # this list of conditions and the following disclaimer. 9 | # 2. Redistributions in binary form must reproduce the above copyright 10 | # notice, this list of conditions and the following disclaimer in the 11 | # documentation and/or other materials provided with the distribution. 12 | # 13 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 14 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED 15 | # TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 16 | # PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR 17 | # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 18 | # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 19 | # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; 20 | # OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, 21 | # WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR 22 | # OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF 23 | # ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | 25 | #pylint:disable=unused-import,import-outside-toplevel 26 | #pylint:disable=no-name-in-module,import-error 27 | import re 28 | from functools import WRAPPER_ASSIGNMENTS 29 | import six 30 | 31 | from six.moves.urllib.parse import urljoin, urlparse, urlsplit, urlunparse 32 | 33 | 34 | try: 35 | from django.utils.decorators import available_attrs 36 | except ImportError: # django < 3.0 37 | def available_attrs(func): #pylint:disable=unused-argument 38 | return WRAPPER_ASSIGNMENTS 39 | 40 | try: 41 | from django.utils.encoding import python_2_unicode_compatible 42 | except ImportError: # django < 3.0 43 | python_2_unicode_compatible = six.python_2_unicode_compatible 44 | 45 | 46 | from django import VERSION as DJANGO_VERSION 47 | from django.conf import settings as django_settings 48 | from django.template import RequestContext 49 | 50 | try: 51 | from django.templatetags.static import do_static 52 | except ImportError: # django < 2.1 53 | from django.contrib.staticfiles.templatetags import do_static 54 | 55 | try: 56 | from django.template.context_processors import csrf 57 | except ImportError: # django < 1.8 58 | from django.core.context_processors import csrf #pylint:disable=import-error 59 | 60 | try: 61 | from django.template.exceptions import TemplateDoesNotExist 62 | except ImportError: 63 | from django.template.base import TemplateDoesNotExist 64 | 65 | try: 66 | from django.urls import NoReverseMatch, reverse, reverse_lazy 67 | except ImportError: # <= Django 1.10, Python<3.6 68 | from django.core.urlresolvers import NoReverseMatch, reverse, reverse_lazy 69 | except ModuleNotFoundError: #pylint:disable=undefined-variable,bad-except-order 70 | # <= Django 1.10, Python>=3.6 71 | from django.core.urlresolvers import NoReverseMatch, reverse, reverse_lazy 72 | 73 | try: 74 | from django.urls import include, path, re_path 75 | except ImportError: # <= Django 2.0, Python<3.6 76 | from django.conf.urls import include, url as re_path 77 | 78 | def path(route, view, kwargs=None, name=None): 79 | re_route = re.sub( 80 | r'', 81 | r'(?P<\1>[0-9]+)', 82 | re.sub(r'', 83 | r'(?P<\1>([a-zA-Z0-9\-]+/)*[a-zA-Z0-9\-]+)', 84 | re.sub(r'', 85 | r'(?P<\1>[a-zA-Z0-9_\-\+\.]+)', 86 | route))) 87 | return re_path(re_route, view, kwargs=kwargs, name=name) 88 | 89 | try: 90 | if six.PY3: 91 | from django.utils.encoding import force_str 92 | else: 93 | from django.utils.encoding import force_text as force_str 94 | except ImportError: # django < 3.0 95 | from django.utils.encoding import force_text as force_str 96 | 97 | 98 | try: 99 | from django.utils.module_loading import import_string 100 | except ImportError: # django < 1.7 101 | from django.utils.module_loading import import_by_path as import_string 102 | 103 | 104 | try: 105 | from django.utils.translation import gettext_lazy 106 | except ImportError: # django < 3.0 107 | from django.utils.translation import ugettext_lazy as gettext_lazy 108 | 109 | 110 | try: 111 | from django.core.files.storage import storages # Added in Django 4.2 112 | def get_storage_class(): 113 | return import_string(storages.backends['default']['BACKEND']) 114 | except ImportError: 115 | from django.core.files.storage import get_storage_class # Removed in Django 5.0 116 | 117 | 118 | def get_loaders(): 119 | loaders = [] 120 | try: 121 | from django.template.loader import _engine_list 122 | engines = _engine_list() 123 | for engine in engines: 124 | try: 125 | loaders += engine.engine.template_loaders 126 | except AttributeError: 127 | pass 128 | try: 129 | loaders += engine.template_loaders 130 | except AttributeError: 131 | pass 132 | 133 | except ImportError:# django < 1.8 134 | from django.template.loader import find_template_loader 135 | for loader_name in django_settings.TEMPLATE_LOADERS: 136 | template_loader = find_template_loader(loader_name) 137 | if template_loader is not None: 138 | loaders.append(template_loader) 139 | return loaders 140 | 141 | 142 | def render_template(template, context, request): 143 | """ 144 | In Django 1.7 django.template.Template.render(self, context) 145 | In Django 1.8 django.template.backends.django.Template.render( 146 | self, context=None, request=None) 147 | """ 148 | if DJANGO_VERSION[0] == 1 and DJANGO_VERSION[1] < 8: 149 | context = RequestContext(request, context) 150 | return template.render(context) 151 | return template.render(context, request) 152 | 153 | 154 | try: 155 | from django.template.base import DebugLexer 156 | except ImportError: # django < 1.8 157 | from django.template.debug import DebugLexer as BaseDebugLexer 158 | 159 | class DebugLexer(BaseDebugLexer): 160 | 161 | def __init__(self, template_string): 162 | super(DebugLexer, self).__init__(template_string, origin=None) 163 | 164 | 165 | try: 166 | from django.template.base import TokenType 167 | except ImportError: # django < 2.1 168 | from django.template.base import (TOKEN_TEXT, TOKEN_VAR, TOKEN_BLOCK, 169 | TOKEN_COMMENT) 170 | class TokenType(object): 171 | TEXT = TOKEN_TEXT 172 | VAR = TOKEN_VAR 173 | BLOCK = TOKEN_BLOCK 174 | COMMENT = TOKEN_COMMENT 175 | 176 | 177 | class DjangoTemplate(object): 178 | 179 | @property 180 | def template_builtins(self): 181 | from django.template.base import builtins 182 | return builtins 183 | 184 | @property 185 | def template_libraries(self): 186 | from django.template.base import libraries 187 | return libraries 188 | 189 | 190 | def get_html_engine(): 191 | try: 192 | from django.template import engines 193 | from django.template.utils import InvalidTemplateEngineError 194 | try: 195 | return engines['html'], None, None 196 | except InvalidTemplateEngineError: 197 | engine = engines['django'].engine 198 | return engine, engine.template_libraries, engine.template_builtins 199 | except ImportError: # django < 1.8 200 | return DjangoTemplate() 201 | 202 | 203 | def is_authenticated(request): 204 | if callable(request.user.is_authenticated): 205 | return request.user.is_authenticated() 206 | return request.user.is_authenticated 207 | -------------------------------------------------------------------------------- /pages/api/reactions.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2025, DjaoDjin inc. 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, 8 | # this list of conditions and the following disclaimer. 9 | # 2. Redistributions in binary form must reproduce the above copyright notice, 10 | # this list of conditions and the following disclaimer in the documentation 11 | # and/or other materials provided with the distribution. 12 | # 13 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 14 | # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, 15 | # THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 16 | # PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR 17 | # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 18 | # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 19 | # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; 20 | # OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, 21 | # WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR 22 | # OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF 23 | # ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | from __future__ import unicode_literals 25 | 26 | from deployutils.helpers import datetime_or_now 27 | from django.core.exceptions import PermissionDenied 28 | from django.db import transaction 29 | from rest_framework import generics 30 | 31 | from .. import signals 32 | from ..compat import is_authenticated 33 | from ..mixins import PageElementMixin 34 | from ..models import Comment, Follow, Vote 35 | from ..serializers import CommentSerializer, PageElementDetailSerializer 36 | 37 | 38 | class FollowAPIView(PageElementMixin, generics.CreateAPIView): 39 | """ 40 | Follows a page element 41 | 42 | The authenticated user making the request will receive notification 43 | whenever someone comments on the practice. 44 | 45 | **Tags**: content, user 46 | 47 | **Examples** 48 | 49 | .. code-block:: http 50 | 51 | POST /api/content/follow/adjust-air-fuel-ratio HTTP/1.1 52 | 53 | .. code-block:: json 54 | 55 | {} 56 | 57 | responds 58 | 59 | .. code-block:: json 60 | 61 | { 62 | "slug": "water-user", 63 | "title": "How to reduce water usage?" 64 | } 65 | """ 66 | serializer_class = PageElementDetailSerializer 67 | 68 | def perform_create(self, serializer): 69 | if not is_authenticated(self.request): 70 | raise PermissionDenied() 71 | Follow.objects.subscribe(self.element, user=self.request.user) 72 | serializer.instance = self.element 73 | 74 | 75 | class UnfollowAPIView(PageElementMixin, generics.CreateAPIView): 76 | """ 77 | Unfollows a page element 78 | 79 | The authenticated user making the request will stop receiving notification 80 | whenever someone comments on the practice. 81 | 82 | **Tags**: content, user 83 | 84 | **Examples** 85 | 86 | .. code-block:: http 87 | 88 | POST /api/content/unfollow/adjust-air-fuel-ratio HTTP/1.1 89 | 90 | .. code-block:: json 91 | 92 | {} 93 | 94 | responds 95 | 96 | .. code-block:: json 97 | 98 | { 99 | "slug": "water-user", 100 | "title": "How to reduce water usage?" 101 | } 102 | """ 103 | serializer_class = PageElementDetailSerializer 104 | 105 | def perform_create(self, serializer): 106 | if not is_authenticated(self.request): 107 | raise PermissionDenied() 108 | Follow.objects.unsubscribe(self.element, user=self.request.user) 109 | serializer.instance = self.element 110 | 111 | 112 | class UpvoteAPIView(PageElementMixin, generics.CreateAPIView): 113 | """ 114 | Upvotes a page element 115 | 116 | The authenticated user making the request indicates the practice is 117 | considered worthwhile implementing. 118 | 119 | **Tags**: content, user 120 | 121 | **Examples** 122 | 123 | .. code-block:: http 124 | 125 | POST /api/content/upvote/adjust-air-fuel-ratio HTTP/1.1 126 | 127 | .. code-block:: json 128 | 129 | {} 130 | 131 | responds 132 | 133 | .. code-block:: json 134 | 135 | { 136 | "slug": "water-user", 137 | "title": "How to reduce water usage?" 138 | } 139 | """ 140 | serializer_class = PageElementDetailSerializer 141 | 142 | def perform_create(self, serializer): 143 | if not is_authenticated(self.request): 144 | raise PermissionDenied() 145 | Vote.objects.vote_up(self.element, user=self.request.user) 146 | serializer.instance = self.element 147 | 148 | 149 | class DownvoteAPIView(PageElementMixin, generics.CreateAPIView): 150 | """ 151 | Downvotes a page element 152 | 153 | The authenticated user making the request indicates the practice is 154 | not worth implementing. 155 | 156 | **Tags**: content, user 157 | 158 | **Examples** 159 | 160 | .. code-block:: http 161 | 162 | POST /api/content/downvote/adjust-air-fuel-ratio HTTP/1.1 163 | 164 | .. code-block:: json 165 | 166 | {} 167 | 168 | responds 169 | 170 | .. code-block:: json 171 | 172 | { 173 | "slug": "water-user", 174 | "title": "How to reduce water usage?" 175 | } 176 | """ 177 | serializer_class = PageElementDetailSerializer 178 | 179 | def perform_create(self, serializer): 180 | if not is_authenticated(self.request): 181 | raise PermissionDenied() 182 | Vote.objects.vote_down(self.element, user=self.request.user) 183 | serializer.instance = self.element 184 | 185 | 186 | class CommentListCreateAPIView(PageElementMixin, generics.ListCreateAPIView): 187 | 188 | serializer_class = CommentSerializer 189 | 190 | def get_queryset(self): 191 | return Comment.objects.filter( 192 | element=self.element).select_related('user').order_by('created_at') 193 | 194 | def get(self, request, *args, **kwargs): 195 | """ 196 | Lists comments on a page element 197 | 198 | **Tags**: content 199 | 200 | **Examples** 201 | 202 | .. code-block:: http 203 | 204 | GET /api/content/comments/adjust-air-fuel-ratio HTTP/1.1 205 | 206 | responds 207 | 208 | .. code-block:: json 209 | 210 | { 211 | "count": 1, 212 | "next": null, 213 | "previous": null, 214 | "results": [ 215 | { 216 | "created_at": "2020-09-28T00:00:00.0000Z", 217 | "user": "steve", 218 | "text": "How long does it take to see improvements?" 219 | } 220 | ] 221 | } 222 | """ 223 | #pylint:disable=useless-super-delegation 224 | return super(CommentListCreateAPIView, self).get( 225 | request, *args, **kwargs) 226 | 227 | def post(self, request, *args, **kwargs): 228 | """ 229 | Comments on a page element 230 | 231 | **Tags**: content, user 232 | 233 | **Examples** 234 | 235 | .. code-block:: http 236 | 237 | POST /api/content/comments/adjust-air-fuel-ratio HTTP/1.1 238 | 239 | .. code-block:: json 240 | 241 | { 242 | "text": "How long does it take to see improvements?" 243 | } 244 | 245 | responds 246 | 247 | .. code-block:: json 248 | 249 | { 250 | "created_at": "2020-09-28T00:00:00.0000Z", 251 | "user": "steve", 252 | "text": "How long does it take to see improvements?" 253 | } 254 | """ 255 | #pylint:disable=useless-super-delegation 256 | return super(CommentListCreateAPIView, self).post( 257 | request, *args, **kwargs) 258 | 259 | def perform_create(self, serializer): 260 | if not is_authenticated(self.request): 261 | raise PermissionDenied() 262 | 263 | with transaction.atomic(): 264 | serializer.save(created_at=datetime_or_now(), 265 | element=self.element, user=self.request.user) 266 | # Subscribe the commenting user to this element 267 | Follow.objects.subscribe(self.element, user=self.request.user) 268 | 269 | signals.comment_was_posted.send( 270 | sender=__name__, comment=serializer.instance, request=self.request) 271 | -------------------------------------------------------------------------------- /testsite/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for testsite project. 3 | 4 | For more information on this file, see 5 | https://docs.djangoproject.com/en/1.7/topics/settings/ 6 | 7 | For the full list of settings and their values, see 8 | https://docs.djangoproject.com/en/1.7/ref/settings/ 9 | """ 10 | 11 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 12 | import logging, os, re, sys 13 | 14 | BASE_DIR = os.path.dirname(os.path.dirname(__file__)) 15 | RUN_DIR = os.getenv('RUN_DIR', os.getcwd()) 16 | DB_NAME = os.path.join(RUN_DIR, 'db.sqlite') 17 | LOG_FILE = os.path.join(RUN_DIR, 'testsite-app.log') 18 | 19 | DEBUG = True 20 | ALLOWED_HOSTS = ('*',) 21 | APP_NAME = os.path.basename(BASE_DIR) 22 | 23 | 24 | def load_config(confpath): 25 | ''' 26 | Given a path to a file, parse its lines in ini-like format, and then 27 | set them in the current namespace. 28 | ''' 29 | # todo: consider using something like ConfigObj for this: 30 | # http://www.voidspace.org.uk/python/configobj.html 31 | if os.path.isfile(confpath): 32 | sys.stderr.write('config loaded from %s\n' % confpath) 33 | with open(confpath) as conffile: 34 | line = conffile.readline() 35 | while line != '': 36 | if not line.startswith('#'): 37 | look = re.match(r'(\w+)\s*=\s*(.*)', line) 38 | if look: 39 | value = look.group(2) \ 40 | % {'LOCALSTATEDIR': BASE_DIR + '/var'} 41 | # Once Django 1.5 introduced ALLOWED_HOSTS (a tuple 42 | # definitely in the site.conf set), we had no choice 43 | # other than using eval. The {} are here to restrict 44 | # the globals and locals context eval has access to. 45 | # pylint: disable=eval-used 46 | setattr(sys.modules[__name__], 47 | look.group(1).upper(), eval(value, {}, {})) 48 | line = conffile.readline() 49 | else: 50 | sys.stderr.write('warning: config file %s does not exist.\n' % confpath) 51 | 52 | load_config(os.path.join( 53 | os.getenv('TESTSITE_SETTINGS_LOCATION', RUN_DIR), 'credentials')) 54 | load_config(os.path.join( 55 | os.getenv('TESTSITE_SETTINGS_LOCATION', RUN_DIR), 'site.conf')) 56 | 57 | if not hasattr(sys.modules[__name__], "SECRET_KEY"): 58 | from random import choice 59 | SECRET_KEY = "".join([choice( 60 | "abcdefghijklmnopqrstuvwxyz0123456789!@#$%^*-_=+") for i in range(50)]) 61 | 62 | JWT_SECRET_KEY = SECRET_KEY 63 | JWT_ALGORITHM = 'HS256' 64 | 65 | # SECURITY WARNING: don't run with debug turned on in production! 66 | if os.getenv('DEBUG'): 67 | # Enable override on command line. 68 | DEBUG = bool(int(os.getenv('DEBUG')) > 0) 69 | 70 | # Applications 71 | # ------------ 72 | INSTALLED_APPS = ( 73 | 'django_extensions', 74 | 'django.contrib.admin', 75 | 'django.contrib.auth', 76 | 'django.contrib.contenttypes', 77 | 'django.contrib.sessions', 78 | 'django.contrib.messages', 79 | 'django.contrib.staticfiles', 80 | 'rest_framework', 81 | 'debug_toolbar', 82 | 'pages', 83 | 'testsite', 84 | ) 85 | 86 | LOGGING = { 87 | 'version': 1, 88 | 'disable_existing_loggers': False, 89 | 'filters': { 90 | 'require_debug_false': { 91 | '()': 'django.utils.log.RequireDebugFalse' 92 | } 93 | }, 94 | 'handlers': { 95 | 'console': { 96 | 'level': 'DEBUG', 97 | 'class': 'logging.StreamHandler', 98 | }, 99 | 'logfile':{ 100 | 'level':'DEBUG', 101 | 'class':'logging.StreamHandler', 102 | }, 103 | 'mail_admins': { 104 | 'level': 'ERROR', 105 | 'filters': ['require_debug_false'], 106 | 'class': 'django.utils.log.AdminEmailHandler' 107 | } 108 | }, 109 | 'loggers': { 110 | 'pages': { 111 | 'handlers': ['logfile'], 112 | 'level': 'INFO', 113 | 'propagate': False, 114 | }, 115 | # 'django.db.backends': { 116 | # 'handlers': ['logfile'], 117 | # 'level': 'DEBUG', 118 | # 'propagate': True, 119 | # }, 120 | 'django.request': { 121 | 'handlers': ['mail_admins'], 122 | 'level': 'ERROR', 123 | 'propagate': True, 124 | }, 125 | # If we don't disable 'django' handlers here, we will get an extra 126 | # copy on stderr. 127 | 'django': { 128 | 'handlers': [], 129 | }, 130 | # This is the root logger. 131 | # The level will only be taken into account if the record is not 132 | # propagated from a child logger. 133 | #https://docs.python.org/2/library/logging.html#logging.Logger.propagate 134 | '': { 135 | 'handlers': ['logfile', 'mail_admins'], 136 | 'level': 'INFO' 137 | }, 138 | 'fontTools.subset': { 139 | 'handlers': ['console'], 140 | 'level': 'WARNING', 141 | } 142 | } 143 | } 144 | if logging.getLogger('gunicorn.error').handlers: 145 | LOGGING['handlers']['logfile'].update({ 146 | 'class':'logging.handlers.WatchedFileHandler', 147 | 'filename': LOG_FILE 148 | }) 149 | 150 | 151 | MIDDLEWARE = ( 152 | 'whitenoise.middleware.WhiteNoiseMiddleware', 153 | 'debug_toolbar.middleware.DebugToolbarMiddleware', 154 | 'django.middleware.common.CommonMiddleware', 155 | 'django.contrib.sessions.middleware.SessionMiddleware', 156 | 'django.middleware.csrf.CsrfViewMiddleware', 157 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 158 | 'django.contrib.messages.middleware.MessageMiddleware', 159 | 'django.middleware.clickjacking.XFrameOptionsMiddleware' 160 | ) 161 | 162 | ROOT_URLCONF = 'testsite.urls' 163 | WSGI_APPLICATION = 'testsite.wsgi.application' 164 | 165 | 166 | REST_FRAMEWORK = { 167 | 'PAGE_SIZE': 25, 168 | 'DEFAULT_PAGINATION_CLASS': 169 | 'rest_framework.pagination.PageNumberPagination', 170 | 'ORDERING_PARAM': 'o', 171 | 'SEARCH_PARAM': 'q' 172 | } 173 | 174 | # Static assets (CSS, JavaScript, Images) 175 | # -------------------------------------- 176 | HTDOCS = os.path.join(BASE_DIR, 'htdocs') 177 | 178 | STATIC_URL = '/static/' 179 | APP_STATIC_ROOT = HTDOCS + '/static' 180 | if DEBUG: 181 | STATIC_ROOT = '' 182 | # Additional locations of static files 183 | STATICFILES_DIRS = (APP_STATIC_ROOT, HTDOCS,) 184 | else: 185 | STATIC_ROOT = APP_STATIC_ROOT 186 | 187 | # Absolute filesystem path to the directory that will hold user-uploaded files. 188 | # Example: "/var/www/example.com/media/" 189 | MEDIA_ROOT = HTDOCS + '/media' 190 | 191 | # URL that handles the media served from MEDIA_ROOT. Make sure to use a 192 | # trailing slash. 193 | # Examples: "http://example.com/media/", "http://media.example.com/" 194 | MEDIA_URL = '/media/' 195 | 196 | # 197 | # Templates 198 | # --------- 199 | TEMPLATE_DEBUG = True 200 | 201 | TEMPLATE_CONTEXT_PROCESSORS = ( 202 | 'django.contrib.auth.context_processors.auth', 203 | 'django.contrib.messages.context_processors.messages', 204 | 'django.core.context_processors.media', 205 | 'django.core.context_processors.static', 206 | 'django.core.context_processors.request' 207 | ) 208 | 209 | TEMPLATE_DIRS = ( 210 | BASE_DIR + '/testsite/templates', 211 | ) 212 | 213 | # Django 1.8+ 214 | TEMPLATES = [ 215 | { 216 | 'NAME': 'html', 217 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 218 | 'DIRS': TEMPLATE_DIRS, 219 | 'APP_DIRS': True, 220 | 'OPTIONS': { 221 | 'debug': TEMPLATE_DEBUG, 222 | 'context_processors': [proc.replace( 223 | 'django.core.context_processors', 224 | 'django.template.context_processors') 225 | for proc in TEMPLATE_CONTEXT_PROCESSORS]}, 226 | }, 227 | ] 228 | 229 | 230 | # Database 231 | # -------- 232 | DATABASES = { 233 | 'default': { 234 | 'ENGINE': 'django.db.backends.sqlite3', 235 | 'NAME': DB_NAME, 236 | } 237 | } 238 | 239 | DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' 240 | 241 | # Internationalization 242 | # -------------------- 243 | LANGUAGE_CODE = 'en-us' 244 | TIME_ZONE = 'UTC' 245 | USE_I18N = True 246 | USE_L10N = True 247 | USE_TZ = True 248 | 249 | # debug panel 250 | # ----------- 251 | DEBUG_TOOLBAR_PATCH_SETTINGS = False 252 | DEBUG_TOOLBAR_CONFIG = { 253 | 'JQUERY_URL': '/static/vendor/jquery.js', 254 | 'SHOW_COLLAPSED': True, 255 | 'SHOW_TEMPLATE_CONTEXT': True, 256 | } 257 | 258 | INTERNAL_IPS = ('127.0.0.1', '::1') 259 | 260 | # Authentication 261 | # -------------- 262 | LOGIN_URL = 'login' 263 | LOGIN_REDIRECT_URL = '/app/supplier-1/' 264 | 265 | 266 | PAGES = { 267 | 'ACCOUNT_URL_KWARG': 'profile' 268 | } 269 | -------------------------------------------------------------------------------- /testsite/fixtures/default-db.json: -------------------------------------------------------------------------------- 1 | [{ 2 | "fields": { 3 | "date_joined": "2024-01-01T00:00:00Z", 4 | "email": "alice@localhost.localdomain", 5 | "first_name": "Alice", 6 | "is_active": true, 7 | "is_staff": true, 8 | "is_superuser": true, 9 | "last_login": "2024-01-01T00:00:00Z", 10 | "last_name": "Doe", 11 | "password": "pbkdf2_sha256$10000$z0MBiWn0Rlem$iZdC6uHomlE07qGK/TqfcfxNzKJtFp03c0JILF1frRc=", 12 | "username": "alice" 13 | }, 14 | "model": "auth.User", "pk": 2 15 | }, 16 | { 17 | "fields": { 18 | "date_joined": "2024-01-01T00:00:00Z", 19 | "email": "alliance@localhost.localdomain", 20 | "first_name": "Alliance", 21 | "is_active": true, 22 | "is_staff": false, 23 | "is_superuser": false, 24 | "last_login": "2024-01-01T00:00:00Z", 25 | "last_name": "Profile", 26 | "password": "pbkdf2_sha256$10000$z0MBiWn0Rlem$iZdC6uHomlE07qGK/TqfcfxNzKJtFp03c0JILF1frRc=", 27 | "username": "alliance" 28 | }, 29 | "model": "auth.User", "pk": 3 30 | }, 31 | { 32 | "fields": { 33 | "date_joined": "2024-01-01T00:00:00Z", 34 | "email": "steve@localhost.localdomain", 35 | "first_name": "Steve", 36 | "is_active": true, 37 | "is_staff": false, 38 | "is_superuser": false, 39 | "last_login": "2024-01-01T00:00:00Z", 40 | "last_name": "User", 41 | "password": "pbkdf2_sha256$10000$z0MBiWn0Rlem$iZdC6uHomlE07qGK/TqfcfxNzKJtFp03c0JILF1frRc=", 42 | "username": "steve" 43 | }, 44 | "model": "auth.User", "pk": 4 45 | }, 46 | { 47 | "fields": { 48 | "date_joined": "2024-01-01T00:00:00Z", 49 | "email": "kathryn@localhost.localdomain", 50 | "first_name": "Kathryn", 51 | "is_active": true, 52 | "is_staff": false, 53 | "is_superuser": false, 54 | "last_login": "2024-01-01T00:00:00Z", 55 | "last_name": "User", 56 | "password": "pbkdf2_sha256$10000$z0MBiWn0Rlem$iZdC6uHomlE07qGK/TqfcfxNzKJtFp03c0JILF1frRc=", 57 | "username": "kathryn" 58 | }, 59 | "model": "auth.User", "pk": 5 60 | }, 61 | { 62 | "fields": { 63 | "title": "Metal structures & equipment", 64 | "slug": "metal", 65 | "extra": "{\"visibility\":[\"public\"]}", 66 | "text_updated_at": "2024-01-01T00:00:00.000Z", 67 | "account": 2 68 | }, 69 | "model": "pages.PageElement", "pk": 100 70 | }, 71 | { 72 | "fields": { 73 | "title": "Boxes & enclosures", 74 | "slug": "boxes-and-enclosures", 75 | "extra": "{\"searchable\":true,\"visibility\":[\"public\"],\"pagebreak\":true,\"layouts\":[\"sustainability\"]}", 76 | "text_updated_at": "2024-01-01T00:00:00.000Z", 77 | "account": 2 78 | }, 79 | "model": "pages.PageElement", "pk": 101 80 | }, 81 | { 82 | "fields": { 83 | "orig_element": 100, "dest_element": 101 84 | }, 85 | "model": "pages.RelationShip", "pk": 100 86 | }, 87 | { 88 | "fields": { 89 | "title": "Production", 90 | "slug": "production", 91 | "picture": "/static/img/production.png", 92 | "extra": "{\"searchable\":true,\"visibility\":[\"public\"]}", 93 | "text_updated_at": "2024-01-01T00:00:00.000Z", 94 | "account": 2 95 | }, 96 | "model": "pages.PageElement", "pk": 102 97 | }, 98 | { 99 | "fields": { 100 | "orig_element": 101, "dest_element": 102, 101 | "rank": 4 102 | }, 103 | "model": "pages.RelationShip", "pk": 101 104 | }, 105 | { 106 | "fields": { 107 | "title": "Energy efficiency", 108 | "slug": "energy-efficiency", 109 | "extra": "{\"searchable\":true,\"visibility\":[\"public\"]}", 110 | "text_updated_at": "2024-01-01T00:00:00.000Z", 111 | "account": 2 112 | }, 113 | "model": "pages.PageElement", "pk": 104 114 | }, 115 | { 116 | "fields": { 117 | "orig_element": 102, "dest_element": 104 118 | }, 119 | "model": "pages.RelationShip", "pk": 103 120 | }, 121 | 122 | { 123 | "fields": { 124 | "title": "Process heating", 125 | "slug": "process-heating", 126 | "extra": "{\"searchable\":true,\"visibility\":[\"public\"]}", 127 | "text_updated_at": "2024-01-01T00:00:00.000Z", 128 | "account": 2 129 | }, 130 | "model": "pages.PageElement", "pk": 105 131 | }, 132 | { 133 | "fields": { 134 | "orig_element": 104, "dest_element": 105 135 | }, 136 | "model": "pages.RelationShip", "pk": 105 137 | }, 138 | { 139 | "fields": { 140 | "title": "Adjust air/fuel ratio", 141 | "slug": "adjust-air-fuel-ratio", 142 | "extra": "{\"searchable\":true,\"visibility\":[\"public\"],\"tags\":[\"Energy & Emissions\"]}", 143 | "text": "

    Background

    Some manufacturing processes may involve heating operations.

    ", 144 | "text_updated_at": "2024-01-01T00:00:00.000Z", 145 | "account": 2 146 | }, 147 | "model": "pages.PageElement", "pk": 106 148 | }, 149 | { 150 | "fields": { 151 | "orig_element": 105, "dest_element": 106 152 | }, 153 | "model": "pages.RelationShip", "pk": 106 154 | }, 155 | { 156 | "fields": { 157 | "title": "Webinar on Sustainability", 158 | "slug": "sustainability-webinar", 159 | "extra": "{\"searchable\":true,\"visibility\":[\"public\"]}", 160 | "text": "

    Join our Webinar!

    Learn about sustainability practices.

    ", 161 | "text_updated_at": "2024-01-01T00:00:00.000Z", 162 | "account": 2 163 | }, 164 | "model": "pages.PageElement", "pk": 107 165 | }, 166 | { 167 | "fields": { 168 | "title": "Certificate of Completion", 169 | "slug": "certificate-of-completion", 170 | "extra": "{\"searchable\":true,\"visibility\":[\"public\"]}", 171 | "text": "

    Congratulations!

    You have completed the course.

    ", 172 | "text_updated_at": "2024-01-01T00:00:00.000Z", 173 | "account": 2 174 | }, 175 | "model": "pages.PageElement", "pk": 108 176 | }, 177 | 178 | { 179 | "fields": { 180 | "title": "Sequence without Certificate", 181 | "slug": "seq", 182 | "account": 2, 183 | "has_certificate": false, 184 | "created_at": "2023-01-01T00:00:00Z" 185 | }, 186 | "model": "pages.Sequence", "pk": 101 187 | }, 188 | { 189 | "fields": { 190 | "sequence": 101, 191 | "content": 100, 192 | "rank": 1, 193 | "min_viewing_duration": "00:00:10" 194 | }, 195 | "model": "pages.EnumeratedElements", "pk": 101 196 | }, 197 | { 198 | "fields": { 199 | "sequence": 101, 200 | "content": 101, 201 | "rank": 2, 202 | "min_viewing_duration": "00:00:20" 203 | }, 204 | "model": "pages.EnumeratedElements", "pk": 102 205 | }, 206 | { 207 | "fields": { 208 | "sequence": 101, 209 | "content": 102, 210 | "rank": 3, 211 | "min_viewing_duration": "00:00:30" 212 | }, 213 | "model": "pages.EnumeratedElements", "pk": 103 214 | }, 215 | { 216 | "fields": { 217 | "title": "Sequence with Certificate", 218 | "slug": "ghg-accounting-training", 219 | "account": 2, 220 | "has_certificate": true, 221 | "created_at": "2023-01-02T00:00:00Z" 222 | }, 223 | "model": "pages.Sequence", "pk": 102 224 | }, 225 | { 226 | "fields": { 227 | "sequence": 102, 228 | "content": 100, 229 | "rank": 1, 230 | "min_viewing_duration": "00:00:10" 231 | }, 232 | "model": "pages.EnumeratedElements", "pk": 104 233 | }, 234 | { 235 | "fields": { 236 | "sequence": 102, 237 | "content": 101, 238 | "rank": 2, 239 | "min_viewing_duration": "00:00:20" 240 | }, 241 | "model": "pages.EnumeratedElements", "pk": 105 242 | }, 243 | { 244 | "fields": { 245 | "sequence": 102, 246 | "content": 108, 247 | "rank": 3, 248 | "min_viewing_duration": "00:00:30" 249 | }, 250 | "model": "pages.EnumeratedElements", "pk": 106 251 | }, 252 | { 253 | "fields": { 254 | "title": "Sequence with Certificate And Live Event", 255 | "slug": "seq-cert-live-event", 256 | "account": 3, 257 | "has_certificate": true, 258 | "created_at": "2023-01-03T00:00:00Z" 259 | }, 260 | "model": "pages.Sequence", "pk": 103 261 | }, 262 | { 263 | "fields": { 264 | "sequence": 103, 265 | "content": 100, 266 | "rank": 1, 267 | "min_viewing_duration": "00:00:10" 268 | }, 269 | "model": "pages.EnumeratedElements", "pk": 107 270 | }, 271 | { 272 | "fields": { 273 | "element": 100, 274 | "created_at": "2023-03-15T10:00:00Z", 275 | "scheduled_at": "2023-04-10T15:00:00Z", 276 | "location": "http://127.0.0.1:8000/webinar/sustainability", 277 | "max_attendees": 100, 278 | "extra": "{}" 279 | }, 280 | "model": "pages.LiveEvent", "pk": 200 281 | }, 282 | { 283 | "fields": { 284 | "sequence": 103, 285 | "content": 101, 286 | "rank": 2, 287 | "min_viewing_duration": "00:00:20" 288 | }, 289 | "model": "pages.EnumeratedElements", "pk": 108 290 | }, 291 | { 292 | "fields": { 293 | "sequence": 103, 294 | "content": 107, 295 | "rank": 3, 296 | "min_viewing_duration": "00:00:30" 297 | }, 298 | "model": "pages.EnumeratedElements", "pk": 109 299 | }, 300 | { 301 | "fields": { 302 | "sequence": 103, 303 | "content": 108, 304 | "rank": 4, 305 | "min_viewing_duration": "00:00:00" 306 | }, 307 | "model": "pages.EnumeratedElements", "pk": 110 308 | }] 309 | -------------------------------------------------------------------------------- /pages/api/relationship.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024, DjaoDjin inc. 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, 8 | # this list of conditions and the following disclaimer. 9 | # 2. Redistributions in binary form must reproduce the above copyright 10 | # notice, this list of conditions and the following disclaimer in the 11 | # documentation and/or other materials provided with the distribution. 12 | # 13 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 14 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED 15 | # TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 16 | # PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR 17 | # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 18 | # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 19 | # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; 20 | # OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, 21 | # WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR 22 | # OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF 23 | # ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | 25 | import logging 26 | from copy import deepcopy 27 | 28 | from django.db import transaction 29 | from django.db.models import Max 30 | from rest_framework import generics 31 | from rest_framework.exceptions import ValidationError 32 | 33 | from ..mixins import TrailMixin 34 | from ..models import RelationShip 35 | from ..serializers import EdgeCreateSerializer 36 | 37 | 38 | LOGGER = logging.getLogger(__name__) 39 | 40 | 41 | class EdgesUpdateAPIView(TrailMixin, generics.CreateAPIView): 42 | 43 | serializer_class = EdgeCreateSerializer 44 | 45 | def rank_or_max(self, root, rank=None): 46 | if rank is None: 47 | rank = self.get_queryset().filter( 48 | orig_element=root).aggregate(Max('rank')).get( 49 | 'rank__max', None) 50 | rank = 0 if rank is None else rank + 1 51 | return rank 52 | 53 | @staticmethod 54 | def valid_against_loop(sources, targets): 55 | if len(sources) <= len(targets): 56 | is_prefix = True 57 | for source, target in zip(sources, targets[:len(sources)]): 58 | if source != target: 59 | is_prefix = False 60 | break 61 | if is_prefix: 62 | raise ValidationError({'detail': "'%s' cannot be attached"\ 63 | " under '%s' as it is a leading prefix. That would create"\ 64 | " a loop." % ( 65 | " > ".join([source.title for source in sources]), 66 | " > ".join([target.title for target in targets]))}) 67 | 68 | 69 | def perform_change(self, sources, targets, rank=None): 70 | # Implemented in subclasses 71 | raise RuntimeError( 72 | "calling abstract method EdgesUpdateAPIView.perform_change") 73 | 74 | def perform_create(self, serializer): 75 | targets = self.get_full_element_path(self.path) 76 | sources = self.get_full_element_path(serializer.validated_data.get( 77 | 'source')) 78 | self.valid_against_loop(sources, targets) 79 | self.perform_change(sources, targets, 80 | rank=serializer.validated_data.get('rank', None)) 81 | 82 | 83 | class PageElementAliasAPIView(EdgesUpdateAPIView): 84 | """ 85 | Aliases the content of an editable node 86 | 87 | **Examples 88 | 89 | .. code-block:: http 90 | 91 | POST /api/editables/tspproject/content/alias/construction HTTP/1.1 92 | 93 | .. code-block:: json 94 | 95 | { 96 | "source": "getting-started" 97 | } 98 | 99 | responds 100 | 101 | .. code-block:: json 102 | 103 | { 104 | "source": "getting-started" 105 | } 106 | """ 107 | queryset = RelationShip.objects.all() 108 | 109 | def perform_change(self, sources, targets, rank=None): 110 | root = targets[-1] 111 | node = sources[-1] 112 | LOGGER.debug("alias node %s under %s with rank=%s", node, root, rank) 113 | with transaction.atomic(): 114 | RelationShip.objects.create( 115 | orig_element=root, dest_element=node, 116 | rank=self.rank_or_max(rank)) 117 | return node 118 | 119 | 120 | class PageElementMirrorAPIView(EdgesUpdateAPIView): 121 | """ 122 | Mirrors the content of an editable node 123 | 124 | Mirrors the content of a PageElement and attach the mirror 125 | under another node. 126 | 127 | **Examples 128 | 129 | .. code-block:: http 130 | 131 | POST /api/editables/tspproject/content/mirror/construction HTTP/1.1 132 | 133 | .. code-block:: json 134 | 135 | { 136 | "source": "/boxes-enclosure/governance" 137 | } 138 | 139 | responds 140 | 141 | .. code-block:: json 142 | 143 | { 144 | "source": "/boxes-enclosure/governance" 145 | } 146 | """ 147 | queryset = RelationShip.objects.all() 148 | 149 | @staticmethod 150 | def mirror_leaf(leaf, prefix="", new_prefix=""): 151 | #pylint:disable=unused-argument 152 | return leaf 153 | 154 | def mirror_recursive(self, root, prefix="", new_prefix=""): 155 | edges = RelationShip.objects.filter( 156 | orig_element=root).select_related('dest_element') 157 | if not edges: 158 | return self.mirror_leaf(root, prefix=prefix, new_prefix=new_prefix) 159 | new_root = deepcopy(root) 160 | new_root.pk = None 161 | new_root.slug = None 162 | new_root.save() 163 | prefix = prefix + "/" + root.slug 164 | new_prefix = new_prefix + "/" + new_root.slug 165 | for edge in edges: 166 | new_edge = deepcopy(edge) 167 | new_edge.pk = None 168 | new_edge.orig_element = new_root 169 | new_edge.dest_element = self.mirror_recursive( 170 | edge.dest_element, prefix=prefix, new_prefix=new_prefix) 171 | new_edge.save() 172 | return new_root 173 | 174 | def perform_change(self, sources, targets, rank=None): 175 | root = targets[-1] 176 | node = sources[-1] 177 | LOGGER.debug("mirror node %s under %s with rank=%s", node, root, rank) 178 | with transaction.atomic(): 179 | prefix = '/%s' % "/".join([elm.slug for elm in sources[:-1]]) 180 | new_prefix = '/%s' % "/".join([elm.slug for elm in targets]) 181 | new_node = self.mirror_recursive(node, 182 | prefix=prefix, new_prefix=new_prefix) 183 | # special case when we are mirroring a leaf element that already 184 | # exist under the root node (ref: `new_node == node` 185 | # through `mirror_leaf`). 186 | RelationShip.objects.get_or_create( 187 | orig_element=root, dest_element=new_node, 188 | defaults={'rank': self.rank_or_max(root, rank)}) 189 | return new_node 190 | 191 | 192 | class PageElementMoveAPIView(EdgesUpdateAPIView): 193 | """ 194 | Moves an editable node 195 | 196 | Moves a PageElement from one attachement to another. 197 | 198 | **Examples 199 | 200 | .. code-block:: http 201 | 202 | POST /api/editables/tspproject/content/attach/construction HTTP/1.1 203 | 204 | .. code-block:: json 205 | 206 | { 207 | "source": "/boxes-enclosures/governance" 208 | } 209 | 210 | responds 211 | 212 | .. code-block:: json 213 | 214 | { 215 | "source": "/boxes-enclosures/governance" 216 | } 217 | """ 218 | queryset = RelationShip.objects.all() 219 | 220 | def perform_change(self, sources, targets, rank=None): 221 | if len(sources) < 2 or len(targets) < 1: 222 | LOGGER.error("There will be a problem calling "\ 223 | " perform_change(sources=%s, targets=%s, rank=%s)"\ 224 | " - data=%s", sources, targets, rank, self.request.data, 225 | extra={'request': self.request}) 226 | old_root = sources[-2] 227 | root = targets[-1] 228 | LOGGER.debug("update node %s to be under %s with rank=%s", 229 | sources[-1], root, rank) 230 | with transaction.atomic(): 231 | edge = RelationShip.objects.get( 232 | orig_element=old_root, dest_element=sources[-1]) 233 | if rank is None: 234 | rank = self.rank_or_max(root, rank) 235 | else: 236 | RelationShip.objects.insert_available_rank(root, pos=rank, 237 | node=sources[-1] if root == old_root else None) 238 | if root != old_root: 239 | edge.orig_element = root 240 | edge.rank = rank 241 | edge.save() 242 | return sources[-1] 243 | -------------------------------------------------------------------------------- /pages/views/elements.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024, Djaodjin Inc. 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, 8 | # this list of conditions and the following disclaimer. 9 | # 2. Redistributions in binary form must reproduce the above copyright notice, 10 | # this list of conditions and the following disclaimer in the documentation 11 | # and/or other materials provided with the distribution. 12 | # 13 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 14 | # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, 15 | # THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 16 | # PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR 17 | # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 18 | # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 19 | # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; 20 | # OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, 21 | # WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR 22 | # OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF 23 | # ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | import logging, os 25 | 26 | from django.http import Http404 27 | from django.views.generic import TemplateView 28 | 29 | from .. import settings 30 | from ..compat import NoReverseMatch, reverse, six 31 | from ..helpers import get_extra, update_context_urls 32 | from ..mixins import AccountMixin, TrailMixin 33 | from ..models import RelationShip 34 | 35 | 36 | LOGGER = logging.getLogger(__name__) 37 | 38 | 39 | class PageElementView(TrailMixin, TemplateView): 40 | """ 41 | When {path} points to an internal node in the content DAG, an index 42 | page is created that contains the children (up to `pagebreak`) 43 | of that node that are both visible and searchable. 44 | """ 45 | template_name = 'pages/element.html' 46 | account_url_kwarg = settings.ACCOUNT_URL_KWARG 47 | direct_text_load = False 48 | 49 | def get_reverse_kwargs(self): 50 | """ 51 | List of kwargs taken from the url that needs to be passed through 52 | to ``reverse``. 53 | """ 54 | return [self.path_url_kwarg] 55 | 56 | def get_url_kwargs(self, **kwargs): 57 | url_kwargs = {} 58 | if not kwargs: 59 | kwargs = self.kwargs 60 | for url_kwarg in self.get_reverse_kwargs(): 61 | url_kwarg_val = kwargs.get(url_kwarg, None) 62 | if url_kwarg_val: 63 | url_kwargs.update({url_kwarg: url_kwarg_val}) 64 | return url_kwargs 65 | 66 | @property 67 | def is_prefix(self): 68 | #pylint:disable=attribute-defined-outside-init 69 | if not hasattr(self, '_is_prefix'): 70 | try: 71 | self._is_prefix = (not self.element or 72 | (RelationShip.objects.filter( 73 | orig_element=self.element).exists() and 74 | not self.element.text 75 | )) 76 | except Http404: 77 | self._is_prefix = True 78 | return self._is_prefix 79 | 80 | def get_template_names(self): 81 | candidates = [] 82 | if self.element: 83 | candidates += ["pages/%s.html" % layout 84 | for layout in get_extra(self.element, 'layouts', [])] 85 | if self.is_prefix: 86 | # It is not a leaf, let's return the list view 87 | candidates += [os.path.join(os.path.dirname( 88 | self.template_name), 'index.html')] 89 | else: 90 | candidates += super(PageElementView, self).get_template_names() 91 | return candidates 92 | 93 | def get_context_data(self, **kwargs): 94 | context = super(PageElementView, self).get_context_data(**kwargs) 95 | url_kwargs = self.get_url_kwargs(**kwargs) 96 | path = url_kwargs.pop('path', None) 97 | update_context_urls(context, { 98 | # We cannot use `kwargs=url_kwargs` here otherwise 99 | # it will pick up the overriden definition of 100 | # `get_reverse_kwargs` in PageElementEditableView. 101 | 'pages_index': reverse('pages_index') 102 | }) 103 | if self.is_prefix: 104 | if isinstance(path, six.string_types): 105 | path = path.strip(self.URL_PATH_SEP) 106 | if path: 107 | url_kwargs = {'path': path} 108 | update_context_urls(context, { 109 | 'api_content': reverse('api_content', kwargs=url_kwargs), 110 | }) 111 | else: 112 | update_context_urls(context, { 113 | # We cannot use `kwargs=url_kwargs` here otherwise 114 | # it will pick up the overriden definition of 115 | # `get_reverse_kwargs` in PageElementEditableView. 116 | 'api_content': reverse('api_content_index'), 117 | }) 118 | else: 119 | url_kwargs = {'path': self.element.slug} 120 | update_context_urls(context, { 121 | 'api_content': reverse('api_content', kwargs=url_kwargs), 122 | }) 123 | if self.direct_text_load: 124 | context.update({ 125 | 'element': { 126 | 'slug': self.element.slug, 127 | 'title': self.element.title, 128 | 'picture': self.element.picture, 129 | 'text' : self.element.text, 130 | 'tags': get_extra(self.element, 'tags', []) 131 | } 132 | }) 133 | update_context_urls(context, { 134 | 'api_follow': reverse('pages_api_follow', 135 | args=(self.element,)), 136 | 'api_unfollow': reverse('pages_api_unfollow', 137 | args=(self.element,)), 138 | 'api_downvote': reverse('pages_api_downvote', 139 | args=(self.element,)), 140 | 'api_upvote': reverse('pages_api_upvote', 141 | args=(self.element,)), 142 | 'api_comments': reverse('pages_api_comments', 143 | args=(self.element,)), 144 | }) 145 | return context 146 | 147 | 148 | class PageElementEditableView(AccountMixin, PageElementView): 149 | """ 150 | When {path} points to an internal node in the content DAG, an index 151 | page is created that contains the direct children of that belongs 152 | to the `account`. 153 | """ 154 | template_name = 'pages/editables/element.html' 155 | breadcrumb_url = 'pages_editables_element' 156 | 157 | def get_reverse_kwargs(self): 158 | """ 159 | List of kwargs taken from the url that needs to be passed through 160 | to ``reverse``. 161 | """ 162 | kwargs_keys = super(PageElementEditableView, self).get_reverse_kwargs() 163 | if self.account_url_kwarg: 164 | kwargs_keys += [self.account_url_kwarg] 165 | return kwargs_keys 166 | 167 | def get_context_data(self, **kwargs): 168 | context = super( 169 | PageElementEditableView, self).get_context_data(**kwargs) 170 | url_kwargs = self.get_url_kwargs(**kwargs) 171 | path = url_kwargs.pop('path', None) 172 | update_context_urls(context, { 173 | 'pages_index': reverse('pages_editables_index', kwargs=url_kwargs) 174 | }) 175 | if self.is_prefix: 176 | if isinstance(path, six.string_types): 177 | path = path.strip(self.URL_PATH_SEP) 178 | if path: 179 | url_kwargs = { 180 | 'path': path, 181 | self.account_url_kwarg: self.element.account, 182 | } 183 | update_context_urls(context, { 184 | 'api_content': reverse('pages_api_edit_element', 185 | kwargs=url_kwargs), 186 | }) 187 | else: 188 | update_context_urls(context, { 189 | 'api_content': reverse('pages_api_editables_index', 190 | kwargs=url_kwargs), 191 | }) 192 | else: 193 | url_kwargs = { 194 | 'path': self.element.slug, 195 | self.account_url_kwarg: self.element.account, 196 | } 197 | update_context_urls(context, { 198 | 'api_content': reverse('pages_api_edit_element', 199 | kwargs=url_kwargs), 200 | }) 201 | try: 202 | update_context_urls(context, { 203 | 'edit': { 204 | 'api_medias': reverse( 205 | 'uploaded_media_elements', 206 | args=(self.element.account, self.element)), 207 | }}) 208 | except NoReverseMatch: 209 | # There is no API end-point to upload POD assets (images, 210 | # etc.) 211 | pass 212 | 213 | return context 214 | -------------------------------------------------------------------------------- /pages/api/progress.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024, Djaodjin Inc. 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, 8 | # this list of conditions and the following disclaimer. 9 | # 2. Redistributions in binary form must reproduce the above copyright notice, 10 | # this list of conditions and the following disclaimer in the documentation 11 | # and/or other materials provided with the distribution. 12 | # 13 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 14 | # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, 15 | # THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 16 | # PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR 17 | # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 18 | # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 19 | # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; 20 | # OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, 21 | # WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR 22 | # OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF 23 | # ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | 25 | from datetime import timedelta 26 | 27 | from deployutils.helpers import datetime_or_now 28 | from rest_framework import response as api_response, status 29 | from rest_framework.exceptions import ValidationError 30 | from rest_framework.generics import DestroyAPIView, ListAPIView, RetrieveAPIView 31 | 32 | from .. import settings 33 | from ..compat import gettext_lazy as _ 34 | from ..docs import extend_schema 35 | from ..mixins import EnumeratedProgressMixin, SequenceProgressMixin 36 | from ..models import EnumeratedElements, EnumeratedProgress, LiveEvent 37 | from ..serializers import EnumeratedProgressSerializer 38 | 39 | 40 | class EnumeratedProgressListAPIView(SequenceProgressMixin, ListAPIView): 41 | """ 42 | Lists progress for a user on a sequence 43 | 44 | **Tags**: content, progress 45 | 46 | **Example** 47 | 48 | .. code-block:: http 49 | 50 | GET /api/progress/steve/ghg-accounting-training HTTP/1.1 51 | 52 | responds 53 | 54 | .. code-block:: json 55 | 56 | { 57 | "count": 1, 58 | "next": null, 59 | "previous": null, 60 | "results": [ 61 | { 62 | "rank": 1, 63 | "content": "ghg-emissions-scope3-details", 64 | "viewing_duration": "00:00:00" 65 | } 66 | ] 67 | } 68 | """ 69 | serializer_class = EnumeratedProgressSerializer 70 | 71 | def get_queryset(self): 72 | # Implementation Note: 73 | # Couldn't figure out how to return all EnumeratedElements for 74 | # a sequence annotated with the viewing_duration for a specific user. 75 | queryset = EnumeratedElements.objects.raw( 76 | """ 77 | WITH progresses AS ( 78 | SELECT * FROM pages_enumeratedprogress 79 | INNER JOIN pages_sequenceprogress 80 | ON pages_enumeratedprogress.sequence_progress_id = pages_sequenceprogress.id 81 | WHERE pages_sequenceprogress.user_id = %(user_id)d 82 | ) 83 | SELECT * 84 | FROM pages_enumeratedelements 85 | LEFT OUTER JOIN progresses 86 | ON pages_enumeratedelements.id = progresses.step_id 87 | WHERE pages_enumeratedelements.sequence_id = %(sequence_id)d 88 | """ % { 89 | 'user_id': self.user.pk, 90 | 'sequence_id': self.sequence.pk 91 | }) 92 | return queryset 93 | 94 | def paginate_queryset(self, queryset): 95 | try: 96 | page = super( 97 | EnumeratedProgressListAPIView, self).paginate_queryset(queryset) 98 | except TypeError: 99 | # Python2.7/Django1.11 doesn't support `len` on `RawQuerySet`. 100 | page = super(EnumeratedProgressListAPIView, self).paginate_queryset( 101 | list(queryset)) 102 | results = page if page else queryset 103 | for elem in results: 104 | if (elem.viewing_duration is not None and 105 | not isinstance(elem.viewing_duration, timedelta)): 106 | elem.viewing_duration = timedelta( 107 | microseconds=elem.viewing_duration) 108 | return results 109 | 110 | 111 | class EnumeratedProgressResetAPIView(SequenceProgressMixin, DestroyAPIView): 112 | """ 113 | Resets a user's progress on a sequence 114 | 115 | **Tags**: editors, progress, provider 116 | 117 | **Example** 118 | 119 | .. code-block:: http 120 | 121 | DELETE /api/attendance/alliance/ghg-accounting-training/steve HTTP/1.1 122 | 123 | responds 124 | 125 | 204 No Content 126 | """ 127 | def delete(self, request, *args, **kwargs): 128 | EnumeratedProgress.objects.filter( 129 | sequence_progress__user=self.user, 130 | step__sequence=self.sequence).delete() 131 | return api_response.Response(status=status.HTTP_204_NO_CONTENT) 132 | 133 | 134 | class EnumeratedProgressRetrieveAPIView(EnumeratedProgressMixin, 135 | RetrieveAPIView): 136 | """ 137 | Retrieves viewing time for an element 138 | 139 | **Tags**: content, progress 140 | 141 | **Examples** 142 | 143 | .. code-block:: http 144 | 145 | GET /api/progress/steve/ghg-accounting-training/1 HTTP/1.1 146 | 147 | responds 148 | 149 | .. code-block:: json 150 | 151 | { 152 | "rank": 1, 153 | "content": "metal", 154 | "viewing_duration": "00:00:00" 155 | } 156 | """ 157 | serializer_class = EnumeratedProgressSerializer 158 | 159 | def get_object(self): 160 | return self.progress 161 | 162 | @extend_schema(request=None) 163 | def post(self, request, *args, **kwargs): 164 | """ 165 | Updates viewing time for an element 166 | 167 | **Tags**: content, progress 168 | 169 | **Examples** 170 | 171 | .. code-block:: http 172 | 173 | POST /api/progress/steve/ghg-accounting-training/1 HTTP/1.1 174 | 175 | responds 176 | 177 | .. code-block:: json 178 | 179 | { 180 | "rank": 1, 181 | "content": "metal", 182 | "viewing_duration": "00:00:56.000000" 183 | } 184 | """ 185 | instance = self.get_object() 186 | now = datetime_or_now() 187 | 188 | if instance.last_ping_time: 189 | time_elapsed = now - instance.last_ping_time 190 | # Add only the actual time elapsed, with a cap for inactivity 191 | time_increment = min(time_elapsed, timedelta(seconds=settings.PING_INTERVAL+1)) 192 | else: 193 | # Set the initial increment to the expected ping interval (i.e., 10 seconds) 194 | time_increment = timedelta(seconds=settings.PING_INTERVAL) 195 | 196 | instance.viewing_duration += time_increment 197 | instance.last_ping_time = now 198 | instance.save() 199 | 200 | status_code = status.HTTP_200_OK 201 | serializer = self.get_serializer(instance) 202 | return api_response.Response(serializer.data, status=status_code) 203 | 204 | 205 | class LiveEventAttendanceAPIView(EnumeratedProgressRetrieveAPIView): 206 | """ 207 | Retrieves attendance to live event 208 | 209 | **Tags**: content, progress, provider 210 | 211 | **Examples** 212 | 213 | .. code-block:: http 214 | 215 | GET /api/attendance/alliance/ghg-accounting-training/1/steve HTTP/1.1 216 | 217 | responds 218 | 219 | .. code-block:: json 220 | 221 | { 222 | "rank": 1, 223 | "content":"ghg-emissions-scope3-details", 224 | "viewing_duration": "00:00:00", 225 | "min_viewing_duration": "00:01:00" 226 | } 227 | """ 228 | rank_url_kwarg = 'rank' 229 | 230 | @extend_schema(request=None) 231 | def post(self, request, *args, **kwargs): 232 | """ 233 | Marks a user's attendance to a live event 234 | 235 | Indicates that a user attended a live event, hence fullfilling 236 | the requirements for the element of the sequence. 237 | 238 | **Tags**: editors, live-events, attendance, provider 239 | 240 | **Example** 241 | 242 | .. code-block:: http 243 | 244 | POST /api/attendance/alliance/ghg-accounting-training/1/steve \ 245 | HTTP/1.1 246 | 247 | responds 248 | 249 | .. code-block:: json 250 | 251 | { 252 | "rank": 1, 253 | "content":"ghg-emissions-scope3-details", 254 | "viewing_duration": "00:00:00", 255 | "min_viewing_duration": "00:01:00" 256 | } 257 | """ 258 | progress = self.get_object() 259 | element = progress.step 260 | live_event = LiveEvent.objects.filter(element=element.content).first() 261 | 262 | # We use if live_event to confirm the existence of the LiveEvent object 263 | if (not live_event or 264 | progress.viewing_duration > element.min_viewing_duration): 265 | raise ValidationError(_("Cannot mark attendance of %(user)s"\ 266 | " to %(sequence)s:%(rank)s.") % { 267 | 'user': self.user, 'sequence': self.sequence, 268 | 'rank': self.kwargs.get(self.rank_url_kwarg)}) 269 | 270 | progress.viewing_duration = element.min_viewing_duration 271 | progress.save() 272 | serializer = self.get_serializer(instance=progress) 273 | return api_response.Response(serializer.data) 274 | -------------------------------------------------------------------------------- /testsite/static/vendor/jquery.ba-throttle-debounce.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * jQuery throttle / debounce - v1.1 - 3/7/2010 3 | * http://benalman.com/projects/jquery-throttle-debounce-plugin/ 4 | * 5 | * Copyright (c) 2010 "Cowboy" Ben Alman 6 | * Dual licensed under the MIT and GPL licenses. 7 | * http://benalman.com/about/license/ 8 | */ 9 | 10 | // Script: jQuery throttle / debounce: Sometimes, less is more! 11 | // 12 | // *Version: 1.1, Last updated: 3/7/2010* 13 | // 14 | // Project Home - http://benalman.com/projects/jquery-throttle-debounce-plugin/ 15 | // GitHub - http://github.com/cowboy/jquery-throttle-debounce/ 16 | // Source - http://github.com/cowboy/jquery-throttle-debounce/raw/master/jquery.ba-throttle-debounce.js 17 | // (Minified) - http://github.com/cowboy/jquery-throttle-debounce/raw/master/jquery.ba-throttle-debounce.min.js (0.7kb) 18 | // 19 | // About: License 20 | // 21 | // Copyright (c) 2010 "Cowboy" Ben Alman, 22 | // Dual licensed under the MIT and GPL licenses. 23 | // http://benalman.com/about/license/ 24 | // 25 | // About: Examples 26 | // 27 | // These working examples, complete with fully commented code, illustrate a few 28 | // ways in which this plugin can be used. 29 | // 30 | // Throttle - http://benalman.com/code/projects/jquery-throttle-debounce/examples/throttle/ 31 | // Debounce - http://benalman.com/code/projects/jquery-throttle-debounce/examples/debounce/ 32 | // 33 | // About: Support and Testing 34 | // 35 | // Information about what version or versions of jQuery this plugin has been 36 | // tested with, what browsers it has been tested in, and where the unit tests 37 | // reside (so you can test it yourself). 38 | // 39 | // jQuery Versions - none, 1.3.2, 1.4.2 40 | // Browsers Tested - Internet Explorer 6-8, Firefox 2-3.6, Safari 3-4, Chrome 4-5, Opera 9.6-10.1. 41 | // Unit Tests - http://benalman.com/code/projects/jquery-throttle-debounce/unit/ 42 | // 43 | // About: Release History 44 | // 45 | // 1.1 - (3/7/2010) Fixed a bug in where trailing callbacks 46 | // executed later than they should. Reworked a fair amount of internal 47 | // logic as well. 48 | // 1.0 - (3/6/2010) Initial release as a stand-alone project. Migrated over 49 | // from jquery-misc repo v0.4 to jquery-throttle repo v1.0, added the 50 | // no_trailing throttle parameter and debounce functionality. 51 | // 52 | // Topic: Note for non-jQuery users 53 | // 54 | // jQuery isn't actually required for this plugin, because nothing internal 55 | // uses any jQuery methods or properties. jQuery is just used as a namespace 56 | // under which these methods can exist. 57 | // 58 | // Since jQuery isn't actually required for this plugin, if jQuery doesn't exist 59 | // when this plugin is loaded, the method described below will be created in 60 | // the `Cowboy` namespace. Usage will be exactly the same, but instead of 61 | // $.method() or jQuery.method(), you'll need to use Cowboy.method(). 62 | 63 | (function(window,undefined){ 64 | '$:nomunge'; // Used by YUI compressor. 65 | 66 | // Since jQuery really isn't required for this plugin, use `jQuery` as the 67 | // namespace only if it already exists, otherwise use the `Cowboy` namespace, 68 | // creating it if necessary. 69 | var $ = window.jQuery || window.Cowboy || ( window.Cowboy = {} ), 70 | 71 | // Internal method reference. 72 | jq_throttle; 73 | 74 | // Method: jQuery.throttle 75 | // 76 | // Throttle execution of a function. Especially useful for rate limiting 77 | // execution of handlers on events like resize and scroll. If you want to 78 | // rate-limit execution of a function to a single time, see the 79 | // method. 80 | // 81 | // In this visualization, | is a throttled-function call and X is the actual 82 | // callback execution: 83 | // 84 | // > Throttled with `no_trailing` specified as false or unspecified: 85 | // > ||||||||||||||||||||||||| (pause) ||||||||||||||||||||||||| 86 | // > X X X X X X X X X X X X 87 | // > 88 | // > Throttled with `no_trailing` specified as true: 89 | // > ||||||||||||||||||||||||| (pause) ||||||||||||||||||||||||| 90 | // > X X X X X X X X X X 91 | // 92 | // Usage: 93 | // 94 | // > var throttled = jQuery.throttle( delay, [ no_trailing, ] callback ); 95 | // > 96 | // > jQuery('selector').bind( 'someevent', throttled ); 97 | // > jQuery('selector').unbind( 'someevent', throttled ); 98 | // 99 | // This also works in jQuery 1.4+: 100 | // 101 | // > jQuery('selector').bind( 'someevent', jQuery.throttle( delay, [ no_trailing, ] callback ) ); 102 | // > jQuery('selector').unbind( 'someevent', callback ); 103 | // 104 | // Arguments: 105 | // 106 | // delay - (Number) A zero-or-greater delay in milliseconds. For event 107 | // callbacks, values around 100 or 250 (or even higher) are most useful. 108 | // no_trailing - (Boolean) Optional, defaults to false. If no_trailing is 109 | // true, callback will only execute every `delay` milliseconds while the 110 | // throttled-function is being called. If no_trailing is false or 111 | // unspecified, callback will be executed one final time after the last 112 | // throttled-function call. (After the throttled-function has not been 113 | // called for `delay` milliseconds, the internal counter is reset) 114 | // callback - (Function) A function to be executed after delay milliseconds. 115 | // The `this` context and all arguments are passed through, as-is, to 116 | // `callback` when the throttled-function is executed. 117 | // 118 | // Returns: 119 | // 120 | // (Function) A new, throttled, function. 121 | 122 | $.throttle = jq_throttle = function( delay, no_trailing, callback, debounce_mode ) { 123 | // After wrapper has stopped being called, this timeout ensures that 124 | // `callback` is executed at the proper times in `throttle` and `end` 125 | // debounce modes. 126 | var timeout_id, 127 | 128 | // Keep track of the last time `callback` was executed. 129 | last_exec = 0; 130 | 131 | // `no_trailing` defaults to falsy. 132 | if ( typeof no_trailing !== 'boolean' ) { 133 | debounce_mode = callback; 134 | callback = no_trailing; 135 | no_trailing = undefined; 136 | } 137 | 138 | // The `wrapper` function encapsulates all of the throttling / debouncing 139 | // functionality and when executed will limit the rate at which `callback` 140 | // is executed. 141 | function wrapper() { 142 | var that = this, 143 | elapsed = +new Date() - last_exec, 144 | args = arguments; 145 | 146 | // Execute `callback` and update the `last_exec` timestamp. 147 | function exec() { 148 | last_exec = +new Date(); 149 | callback.apply( that, args ); 150 | }; 151 | 152 | // If `debounce_mode` is true (at_begin) this is used to clear the flag 153 | // to allow future `callback` executions. 154 | function clear() { 155 | timeout_id = undefined; 156 | }; 157 | 158 | if ( debounce_mode && !timeout_id ) { 159 | // Since `wrapper` is being called for the first time and 160 | // `debounce_mode` is true (at_begin), execute `callback`. 161 | exec(); 162 | } 163 | 164 | // Clear any existing timeout. 165 | timeout_id && clearTimeout( timeout_id ); 166 | 167 | if ( debounce_mode === undefined && elapsed > delay ) { 168 | // In throttle mode, if `delay` time has been exceeded, execute 169 | // `callback`. 170 | exec(); 171 | 172 | } else if ( no_trailing !== true ) { 173 | // In trailing throttle mode, since `delay` time has not been 174 | // exceeded, schedule `callback` to execute `delay` ms after most 175 | // recent execution. 176 | // 177 | // If `debounce_mode` is true (at_begin), schedule `clear` to execute 178 | // after `delay` ms. 179 | // 180 | // If `debounce_mode` is false (at end), schedule `callback` to 181 | // execute after `delay` ms. 182 | timeout_id = setTimeout( debounce_mode ? clear : exec, debounce_mode === undefined ? delay - elapsed : delay ); 183 | } 184 | }; 185 | 186 | // Set the guid of `wrapper` function to the same of original callback, so 187 | // it can be removed in jQuery 1.4+ .unbind or .die by using the original 188 | // callback as a reference. 189 | if ( $.guid ) { 190 | wrapper.guid = callback.guid = callback.guid || $.guid++; 191 | } 192 | 193 | // Return the wrapper function. 194 | return wrapper; 195 | }; 196 | 197 | // Method: jQuery.debounce 198 | // 199 | // Debounce execution of a function. Debouncing, unlike throttling, 200 | // guarantees that a function is only executed a single time, either at the 201 | // very beginning of a series of calls, or at the very end. If you want to 202 | // simply rate-limit execution of a function, see the 203 | // method. 204 | // 205 | // In this visualization, | is a debounced-function call and X is the actual 206 | // callback execution: 207 | // 208 | // > Debounced with `at_begin` specified as false or unspecified: 209 | // > ||||||||||||||||||||||||| (pause) ||||||||||||||||||||||||| 210 | // > X X 211 | // > 212 | // > Debounced with `at_begin` specified as true: 213 | // > ||||||||||||||||||||||||| (pause) ||||||||||||||||||||||||| 214 | // > X X 215 | // 216 | // Usage: 217 | // 218 | // > var debounced = jQuery.debounce( delay, [ at_begin, ] callback ); 219 | // > 220 | // > jQuery('selector').bind( 'someevent', debounced ); 221 | // > jQuery('selector').unbind( 'someevent', debounced ); 222 | // 223 | // This also works in jQuery 1.4+: 224 | // 225 | // > jQuery('selector').bind( 'someevent', jQuery.debounce( delay, [ at_begin, ] callback ) ); 226 | // > jQuery('selector').unbind( 'someevent', callback ); 227 | // 228 | // Arguments: 229 | // 230 | // delay - (Number) A zero-or-greater delay in milliseconds. For event 231 | // callbacks, values around 100 or 250 (or even higher) are most useful. 232 | // at_begin - (Boolean) Optional, defaults to false. If at_begin is false or 233 | // unspecified, callback will only be executed `delay` milliseconds after 234 | // the last debounced-function call. If at_begin is true, callback will be 235 | // executed only at the first debounced-function call. (After the 236 | // throttled-function has not been called for `delay` milliseconds, the 237 | // internal counter is reset) 238 | // callback - (Function) A function to be executed after delay milliseconds. 239 | // The `this` context and all arguments are passed through, as-is, to 240 | // `callback` when the debounced-function is executed. 241 | // 242 | // Returns: 243 | // 244 | // (Function) A new, debounced, function. 245 | 246 | $.debounce = function( delay, at_begin, callback ) { 247 | return callback === undefined 248 | ? jq_throttle( delay, at_begin, false ) 249 | : jq_throttle( delay, callback, at_begin !== false ); 250 | }; 251 | 252 | })(this); 253 | -------------------------------------------------------------------------------- /pages/api/assets.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2025, Djaodjin Inc. 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, 8 | # this list of conditions and the following disclaimer. 9 | # 2. Redistributions in binary form must reproduce the above copyright notice, 10 | # this list of conditions and the following disclaimer in the documentation 11 | # and/or other materials provided with the distribution. 12 | # 13 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 14 | # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, 15 | # THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 16 | # PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR 17 | # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 18 | # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 19 | # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; 20 | # OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, 21 | # WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR 22 | # OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF 23 | # ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | 25 | 26 | import hashlib, logging, os 27 | 28 | import boto3 29 | from deployutils.helpers import datetime_or_now 30 | from django.core.files.storage import FileSystemStorage 31 | from django.http import HttpResponseRedirect 32 | from django.utils.module_loading import import_string 33 | from rest_framework import parsers, status 34 | from rest_framework.exceptions import ValidationError 35 | from rest_framework.generics import GenericAPIView 36 | from rest_framework.response import Response as HttpResponse 37 | 38 | from .. import settings 39 | from ..compat import (NoReverseMatch, force_str, get_storage_class, 40 | gettext_lazy as _, reverse, urljoin, urlparse) 41 | from ..mixins import AccountMixin 42 | from ..serializers import AssetSerializer 43 | 44 | 45 | LOGGER = logging.getLogger(__name__) 46 | 47 | URL_PATH_SEP = '/' 48 | 49 | 50 | class AssetAPIView(AccountMixin, GenericAPIView): 51 | 52 | store_hash = True 53 | replace_stored = False 54 | content_type = None 55 | serializer_class = AssetSerializer 56 | 57 | @staticmethod 58 | def as_signed_url(location, request): 59 | parts = urlparse(location) 60 | key_name = parts.path.lstrip('/') 61 | # we remove leading '/' otherwise S3 copy triggers a 404 62 | # because it creates an URL with '//'. 63 | storage = get_default_storage(request) 64 | return storage.url(key_name) 65 | 66 | 67 | def get(self, request, *args, **kwargs): 68 | """ 69 | Expiring link to download asset file 70 | 71 | **Examples 72 | 73 | .. code-block:: http 74 | 75 | GET /api/supplier-1/assets/supporting-evidence.pdf HTTP/1.1 76 | 77 | responds 78 | 79 | .. code-block:: json 80 | 81 | { 82 | "location": "https://example-bucket.s3.amazon.com\ 83 | /supporting-evidence.pdf", 84 | "updated_at": "2016-10-26T00:00:00.00000+00:00" 85 | } 86 | """ 87 | #pylint:disable=unused-argument 88 | location = self.as_signed_url(kwargs.get('path'), request) 89 | http_accepts = [item.strip() 90 | for item in request.META.get('HTTP_ACCEPT', '*/*').split(',')] 91 | if 'text/html' in http_accepts: 92 | return HttpResponseRedirect(location) 93 | return HttpResponse(self.get_serializer().to_representation({ 94 | 'location': location})) 95 | 96 | def delete(self, request, *args, **kwargs): 97 | """ 98 | Deletes static assets file 99 | 100 | **Examples 101 | 102 | .. code-block:: http 103 | 104 | DELETE /api/supplier-1/assets/supporting-evidence.pdf HTTP/1.1 105 | 106 | """ 107 | #pylint: disable=unused-variable,unused-argument,too-many-locals 108 | storage = get_default_storage(self.request) 109 | storage.delete(kwargs.get('path')) 110 | return HttpResponse({ 111 | 'detail': _('Media correctly deleted.')}, 112 | status=status.HTTP_200_OK) 113 | 114 | 115 | class UploadAssetAPIView(AccountMixin, GenericAPIView): 116 | 117 | store_hash = True 118 | replace_stored = False 119 | content_type = None 120 | serializer_class = AssetSerializer 121 | parser_classes = (parsers.JSONParser, parsers.FormParser, 122 | parsers.MultiPartParser, parsers.FileUploadParser) 123 | 124 | def post(self, request, *args, **kwargs): 125 | """ 126 | Uploads a static asset file 127 | 128 | **Examples 129 | 130 | .. code-block:: http 131 | 132 | POST /api/supplier-1/assets HTTP/1.1 133 | 134 | responds 135 | 136 | .. code-block:: json 137 | 138 | { 139 | "location": "/media/image-001.jpg", 140 | "updated_at": "2016-10-26T00:00:00.00000+00:00" 141 | } 142 | """ 143 | is_public_asset = request.query_params.get('public', False) 144 | location = request.data.get('location', None) 145 | response_data, response_status = process_upload( 146 | request, self.account, location, is_public_asset, 147 | self.store_hash, self.replace_stored, self.content_type) 148 | return HttpResponse( 149 | AssetSerializer().to_representation(response_data), 150 | status=response_status) 151 | 152 | 153 | def process_upload(request, account=None, location=None, is_public_asset=None, 154 | store_hash=None, replace_stored=None, content_type=None): 155 | #pylint:disable=too-many-arguments,too-many-locals 156 | media_prefix = _get_media_prefix() 157 | response_status = status.HTTP_200_OK 158 | 159 | if location: 160 | parts = urlparse(location) 161 | bucket_name = parts.netloc.split('.')[0] 162 | src_key_name = parts.path.lstrip(URL_PATH_SEP) 163 | # we remove leading '/' otherwise S3 copy triggers a 404 164 | # because it creates an URL with '//'. 165 | prefix = os.path.dirname(src_key_name) 166 | if prefix: 167 | prefix += URL_PATH_SEP 168 | ext = os.path.splitext(src_key_name)[1] 169 | 170 | s3_client = boto3.client('s3') 171 | data = s3_client.get_object(Bucket=bucket_name, Key=src_key_name) 172 | uploaded_file = data['Body'] 173 | if prefix.startswith(media_prefix): 174 | prefix = prefix[len(media_prefix) + 1:] 175 | storage_key_name = "%s%s%s" % (prefix, 176 | hashlib.sha256(uploaded_file.read()).hexdigest(), ext) 177 | 178 | dst_key_name = "%s/%s" % (media_prefix, storage_key_name) 179 | LOGGER.info("S3 bucket %s: copy %s to %s", 180 | bucket_name, src_key_name, dst_key_name) 181 | storage = get_default_storage(request) 182 | if is_public_asset: 183 | extra_args = {'ACL': "public-read"} 184 | else: 185 | extra_args = { 186 | 'ServerSideEncryption': settings.AWS_SERVER_SIDE_ENCRYPTION} 187 | if ext in ['.pdf']: 188 | extra_args.update({'ContentType': 'application/pdf'}) 189 | elif ext in ['.jpg']: 190 | extra_args.update({'ContentType': 'image/jpeg'}) 191 | elif ext in ['.png']: 192 | extra_args.update({'ContentType': 'image/png'}) 193 | s3_client.copy({'Bucket': bucket_name, 'Key': src_key_name}, 194 | bucket_name, dst_key_name, ExtraArgs=extra_args) 195 | # XXX still can't figure out why we get a permission denied on DeleteObject. 196 | # s3_client.delete_object(Bucket=bucket_name, Key=src_key_name) 197 | location = storage.url(storage_key_name) 198 | 199 | elif 'file' in request.data: 200 | uploaded_file = request.data['file'] 201 | if content_type: 202 | # We optionally force the content_type because S3Store uses 203 | # mimetypes.guess and surprisingly it doesn't get it correct 204 | # for 'text/css'. 205 | uploaded_file.content_type = content_type 206 | sha1 = hashlib.sha1(uploaded_file.read()).hexdigest() 207 | 208 | # Store filenames with forward slashes, even on Windows 209 | filename = force_str(uploaded_file.name.replace('\\', '/')) 210 | sha1_filename = sha1 + os.path.splitext(filename)[1] 211 | storage = get_default_storage(request) 212 | stored_filename = sha1_filename if store_hash else filename 213 | if not is_public_asset: 214 | stored_filename = '/'.join( 215 | [str(account), stored_filename]) 216 | 217 | LOGGER.debug("upload %s to %s", filename, stored_filename) 218 | if storage.exists(stored_filename) and replace_stored: 219 | storage.delete(stored_filename) 220 | storage.save(stored_filename, uploaded_file) 221 | response_status = status.HTTP_201_CREATED 222 | location = storage.url(stored_filename) 223 | 224 | else: 225 | raise ValidationError({'detail': 226 | _("Either 'location' or 'file' must be specified.")}) 227 | 228 | if not is_public_asset: 229 | path = urlparse(location).path.lstrip(URL_PATH_SEP) 230 | if path.startswith(media_prefix): 231 | path = path[len(media_prefix):].lstrip(URL_PATH_SEP) 232 | try: 233 | location = request.build_absolute_uri( 234 | reverse('pages_api_asset', args=(account, path,))) 235 | except NoReverseMatch: 236 | location = request.build_absolute_uri( 237 | reverse('pages_api_asset', args=(path,))) 238 | 239 | return ({ 240 | 'location': location, 241 | 'updated_at': datetime_or_now()}, 242 | response_status) 243 | 244 | 245 | def get_default_storage(request, **kwargs): 246 | """ 247 | Returns the default storage for an account. 248 | """ 249 | if settings.DEFAULT_STORAGE_CALLABLE: 250 | storage = import_string(settings.DEFAULT_STORAGE_CALLABLE)( 251 | request, **kwargs) 252 | return storage 253 | return get_default_storage_base(request, **kwargs) 254 | 255 | 256 | def get_default_storage_base(request, public=False, **kwargs): 257 | # default implementation 258 | storage_class = get_storage_class() 259 | if storage_class.__name__.endswith('3Storage'): 260 | # Hacky way to test for `storages.backends.s3.S3Storage` 261 | # and `storages.backends.s3boto3.S3Boto3Storage` without importing 262 | # the optional package 'django-storages'. 263 | storage_kwargs = {} 264 | storage_kwargs.update(**kwargs) 265 | if public: 266 | storage_kwargs.update({'default_acl': 'public-read'}) 267 | bucket_name = _get_bucket_name() 268 | location = _get_media_prefix() 269 | LOGGER.debug("create %s(bucket_name='%s', location='%s', %s)", 270 | storage_class.__name__, bucket_name, location, storage_kwargs) 271 | storage = storage_class(bucket_name=bucket_name, location=location, 272 | **storage_kwargs) 273 | for key in ['access_key', 'secret_key', 'security_token']: 274 | if key in request.session: 275 | setattr(storage, key, request.session[key]) 276 | return storage 277 | LOGGER.debug("``%s`` does not contain a ``bucket_name``"\ 278 | " field, default to FileSystemStorage.", storage_class) 279 | return _get_file_system_storage() 280 | 281 | 282 | def _get_bucket_name(): 283 | return settings.AWS_STORAGE_BUCKET_NAME 284 | 285 | 286 | def _get_file_system_storage(): 287 | location = settings.MEDIA_ROOT 288 | base_url = settings.MEDIA_URL 289 | prefix = _get_media_prefix() 290 | parts = location.split(os.sep) 291 | if prefix and prefix != parts[-1]: 292 | location = os.sep.join(parts[:-1] + [prefix, parts[-1]]) 293 | if base_url.startswith('/'): 294 | base_url = base_url[1:] 295 | base_url = urljoin("/%s/" % prefix, base_url) 296 | return FileSystemStorage(location=location, base_url=base_url) 297 | 298 | 299 | def _get_media_prefix(): 300 | return settings.MEDIA_PREFIX 301 | -------------------------------------------------------------------------------- /pages/api/sequences.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024, Djaodjin Inc. 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, 8 | # this list of conditions and the following disclaimer. 9 | # 2. Redistributions in binary form must reproduce the above copyright notice, 10 | # this list of conditions and the following disclaimer in the documentation 11 | # and/or other materials provided with the distribution. 12 | # 13 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 14 | # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, 15 | # THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 16 | # PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR 17 | # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 18 | # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 19 | # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; 20 | # OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, 21 | # WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR 22 | # OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF 23 | # ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | import logging 25 | 26 | from django.db import transaction, IntegrityError 27 | from django.template.defaultfilters import slugify 28 | from rest_framework import response as api_response, status 29 | from rest_framework.filters import OrderingFilter, SearchFilter 30 | from rest_framework.generics import (get_object_or_404, DestroyAPIView, 31 | ListAPIView, ListCreateAPIView, RetrieveUpdateDestroyAPIView) 32 | 33 | from ..mixins import AccountMixin, SequenceMixin 34 | from ..models import Sequence, EnumeratedElements 35 | from ..serializers import (EnumeratedElementSerializer, SequenceSerializer, 36 | SequenceUpdateSerializer, SequenceCreateSerializer) 37 | 38 | LOGGER = logging.getLogger(__name__) 39 | 40 | 41 | class SequencesIndexAPIView(ListAPIView): 42 | """ 43 | Lists sequences of page elements 44 | 45 | Returns a list of {{PAGE_SIZE}} sequences available to the request user. 46 | 47 | The queryset can be further refined to match a search filter (``q``) 48 | and sorted on specific fields (``o``). 49 | 50 | **Tags: content 51 | 52 | **Example 53 | 54 | .. code-block:: http 55 | 56 | GET /api/content/sequences HTTP/1.1 57 | 58 | responds 59 | 60 | .. code-block:: json 61 | 62 | { 63 | "count": 1, 64 | "next": null, 65 | "previous": null, 66 | "results": [ 67 | { 68 | "created_at": "2024-01-01T00:00:00.0000Z", 69 | "slug": "ghg-accounting-webinar", 70 | "title": "GHG Accounting Training", 71 | "account": "djaopsp", 72 | "has_certificate": true 73 | } 74 | ] 75 | } 76 | """ 77 | queryset = Sequence.objects.all().order_by('slug') 78 | serializer_class = SequenceSerializer 79 | 80 | search_fields = ( 81 | 'title', 82 | 'extra' 83 | ) 84 | ordering_fields = ( 85 | ('title', 'title'), 86 | ) 87 | ordering = ('title',) 88 | 89 | filter_backends = (SearchFilter, OrderingFilter,) 90 | 91 | 92 | class SequenceListCreateAPIView(AccountMixin, ListCreateAPIView): 93 | """ 94 | Lists editable sequences 95 | 96 | Returns a list of {{PAGE_SIZE}} sequences editable by profile. 97 | 98 | The queryset can be further refined to match a search filter (``q``) 99 | and sorted on specific fields (``o``). 100 | 101 | **Tags: editors 102 | 103 | **Example 104 | 105 | .. code-block:: http 106 | 107 | GET /api/editables/alliance/sequences HTTP/1.1 108 | 109 | responds 110 | 111 | .. code-block:: json 112 | 113 | { 114 | "count": 1, 115 | "next": null, 116 | "previous": null, 117 | "results": [ 118 | { 119 | "created_at": "2020-09-28T00:00:00.0000Z", 120 | "slug": "ghg-accounting-webinar", 121 | "title": "GHG Accounting Training", 122 | "account": "djaopsp", 123 | "has_certificate": true 124 | } 125 | ] 126 | } 127 | """ 128 | serializer_class = SequenceSerializer 129 | 130 | search_fields = ( 131 | 'title', 132 | 'extra' 133 | ) 134 | ordering_fields = ( 135 | ('title', 'title'), 136 | ) 137 | ordering = ('title',) 138 | 139 | filter_backends = (SearchFilter, OrderingFilter,) 140 | 141 | 142 | def get_serializer_class(self): 143 | if self.request.method.lower() == 'post': 144 | return SequenceCreateSerializer 145 | return super(SequenceListCreateAPIView, self).get_serializer_class() 146 | 147 | 148 | def get_queryset(self): 149 | """ 150 | Returns a list of heading and best practices 151 | """ 152 | queryset = Sequence.objects.all() 153 | if self.account_url_kwarg in self.kwargs: 154 | queryset = queryset.filter(account=self.account) 155 | return queryset 156 | 157 | def post(self, request, *args, **kwargs): 158 | """ 159 | Creates a sequence of page elements 160 | 161 | Creates a new sequence editable by profile. 162 | 163 | **Tags: editors 164 | 165 | **Example 166 | 167 | .. code-block:: http 168 | 169 | POST /api/editables/alliance/sequences HTTP/1.1 170 | 171 | .. code-block:: json 172 | 173 | { 174 | "slug": "ghg-accounting-webinar", 175 | "title": "GHG Accounting Training" 176 | } 177 | 178 | responds 179 | 180 | .. code-block:: json 181 | 182 | { 183 | "created_at": "2023-01-01T04:00:00.000000Z", 184 | "slug": "ghg-accounting-webinar", 185 | "title": "GHG Accounting Training", 186 | "account": null, 187 | "has_certificate": true 188 | } 189 | """ 190 | return self.create(request, *args, **kwargs) 191 | 192 | 193 | def perform_create(self, serializer): 194 | serializer.save(account=self.account, 195 | slug=slugify(serializer.validated_data['title'])) 196 | 197 | 198 | class SequenceRetrieveUpdateDestroyAPIView(AccountMixin, SequenceMixin, 199 | RetrieveUpdateDestroyAPIView): 200 | """ 201 | Retrieves a sequence 202 | 203 | **Tags: editors 204 | 205 | **Example 206 | 207 | .. code-block:: http 208 | 209 | GET /api/editables/alliance/sequences/ghg-accounting-webinar HTTP/1.1 210 | 211 | responds 212 | 213 | .. code-block:: json 214 | 215 | { 216 | "created_at": "2023-12-29T04:33:33.078661Z", 217 | "slug": "ghg-accounting-webinar", 218 | "title": "GHG Accounting Training", 219 | "account": null, 220 | "has_certificate": true 221 | } 222 | """ 223 | serializer_class = SequenceSerializer 224 | lookup_field = 'slug' 225 | lookup_url_kwarg = SequenceMixin.sequence_url_kwarg 226 | 227 | def get_serializer_class(self): 228 | if self.request.method.lower() == 'put': 229 | return SequenceUpdateSerializer 230 | return super(SequenceRetrieveUpdateDestroyAPIView, 231 | self).get_serializer_class() 232 | 233 | def get_object(self): 234 | return self.sequence 235 | 236 | def delete(self, request, *args, **kwargs): 237 | """ 238 | Deletes a sequence 239 | 240 | **Tags**: editors 241 | 242 | **Examples** 243 | 244 | .. code-block:: http 245 | 246 | DELETE /api/editables/alliance/sequences/ghg-accounting-webinar\ 247 | HTTP/1.1 248 | 249 | """ 250 | return self.destroy(request, *args, **kwargs) 251 | 252 | def put(self, request, *args, **kwargs): 253 | """ 254 | Updates a sequence 255 | 256 | **Tags**: editors 257 | 258 | **Examples** 259 | 260 | .. code-block:: http 261 | 262 | PUT /api/editables/alliance/sequences/ghg-accounting-webinar HTTP/1.1 263 | 264 | .. code-block:: json 265 | 266 | { 267 | "title": "Updated GHG Accounting Training Title", 268 | "has_certificate": false, 269 | "extra": "Additional info" 270 | } 271 | 272 | responds 273 | 274 | .. code-block:: json 275 | 276 | { 277 | "created_at": "2023-12-29T04:33:33.078661Z", 278 | "slug": "ghg-accounting-webinar", 279 | "title": "Updated GHG Accounting Training Title", 280 | "account": null, 281 | "has_certificate": false, 282 | "extra": "Additional info" 283 | } 284 | """ 285 | #pylint:disable=useless-parent-delegation 286 | return self.update(request, *args, **kwargs) 287 | 288 | 289 | class AddElementToSequenceAPIView(AccountMixin, SequenceMixin, 290 | ListCreateAPIView): 291 | """ 292 | Lists page elements in a sequence 293 | 294 | **Tags**: editors 295 | 296 | **Example 297 | 298 | .. code-block:: http 299 | 300 | GET /api/editables/alliance/sequences/ghg-accounting-webinar/elements HTTP/1.1 301 | 302 | responds 303 | 304 | .. code-block:: json 305 | 306 | { 307 | "previous": null, 308 | "next": null, 309 | "count": 2, 310 | "results": [ 311 | { 312 | "rank": 1, 313 | "content": "text-content", 314 | "min_viewing_duration": "00:00:10" 315 | }, 316 | { 317 | "rank": 2, 318 | "content": "survey-event", 319 | "min_viewing_duration": "00:00:20" 320 | } 321 | ] 322 | } 323 | """ 324 | serializer_class = EnumeratedElementSerializer 325 | 326 | def get_queryset(self): 327 | return self.sequence.sequence_enumerated_elements.order_by('rank') 328 | 329 | def post(self, request, *args, **kwargs): 330 | """ 331 | Inserts a page element in a sequence 332 | 333 | **Tags**: editors 334 | 335 | **Example 336 | 337 | .. code-block:: http 338 | 339 | POST /api/editables/alliance/sequences/ghg-accounting-webinar/elements HTTP/1.1 340 | 341 | .. code-block:: json 342 | 343 | { 344 | "content": "production", 345 | "rank": 10 346 | } 347 | 348 | responds 349 | 350 | .. code-block:: json 351 | 352 | { 353 | "rank": 1, 354 | "content": "text-content", 355 | "min_viewing_duration": "00:00:00" 356 | } 357 | """ 358 | serializer = self.get_serializer(data=request.data) 359 | serializer.is_valid(raise_exception=True) 360 | 361 | is_certificate = serializer.validated_data.pop('certificate', False) 362 | rank = serializer.validated_data.get('rank') 363 | 364 | if self.sequence.has_certificate: 365 | last_rank = self.sequence.get_last_element.rank 366 | if is_certificate: 367 | return api_response.Response({ 368 | 'detail': 'The sequence already has a certificate.'}, 369 | status=status.HTTP_400_BAD_REQUEST) 370 | if rank is not None and rank > last_rank: 371 | return api_response.Response({ 372 | 'detail': 'Cannot add an element with a rank higher'\ 373 | ' than the certificate.'}, 374 | status=status.HTTP_400_BAD_REQUEST) 375 | 376 | try: 377 | serializer.save(sequence=self.sequence) 378 | if is_certificate and not self.sequence.has_certificate: 379 | self.sequence.has_certificate = True 380 | self.sequence.save() 381 | serializer = self.get_serializer(serializer.instance) 382 | headers = self.get_success_headers(serializer.data) 383 | return api_response.Response( 384 | serializer.data, status=status.HTTP_201_CREATED, 385 | headers=headers) 386 | except IntegrityError as err: 387 | return api_response.Response( 388 | {'detail': str(err)}, 389 | status=status.HTTP_400_BAD_REQUEST) 390 | 391 | return api_response.Response( 392 | serializer.errors, 393 | status=status.HTTP_400_BAD_REQUEST) 394 | 395 | 396 | class RemoveElementFromSequenceAPIView(AccountMixin, SequenceMixin, 397 | DestroyAPIView): 398 | """ 399 | Removes a page element from a sequence 400 | 401 | **Tags**: editors 402 | 403 | **Example** 404 | 405 | DELETE /api/editables/alliance/sequences/ghg-accounting-webinar/elements/1 HTTP/1.1 406 | 407 | responds 408 | 409 | 204 No Content 410 | """ 411 | def get_object(self): 412 | return get_object_or_404(EnumeratedElements.objects.all(), 413 | sequence=self.sequence, rank=self.kwargs.get('rank')) 414 | 415 | def perform_destroy(self, instance): 416 | with transaction.atomic(): 417 | if (self.sequence.has_certificate and 418 | instance == self.sequence.get_last_element): 419 | self.sequence.has_certificate = False 420 | self.sequence.save() 421 | instance.delete() 422 | --------------------------------------------------------------------------------