├── 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 |
6 | -------------------------------------------------------------------------------- /pages/templates/_paginator.html: -------------------------------------------------------------------------------- 1 ||
6 | [[comment.user]] 7 | [[comment.created_at]] 8 | |
9 | [[comment.text]] | 10 |
Please log in to leave a comment.
21 | {% endif %} 22 |
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('{{ element.content.text|safe }}
13 |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 |Click the button to start tracking your progress.
33 |
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'(?PSome 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": "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": "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