diff --git a/.dockerignore b/.dockerignore index b951028e..d333fb4d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -15,3 +15,4 @@ Dockerfile compose/ places/data/ docker.env +gdoc/ diff --git a/.gitignore b/.gitignore index de4c44de..505fc176 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,6 @@ tmp/*.png *.mo scripts/* docker.env -compose/ \ No newline at end of file +compose/ +token.pickle +credentials.json diff --git a/content/__init__.py b/content/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/content/db.py b/content/db.py new file mode 100644 index 00000000..4bb9e9ea --- /dev/null +++ b/content/db.py @@ -0,0 +1,17 @@ +from django.db import models + + +class Content(models.Model): + + latest = models.ForeignKey('content.ContentVersion', models.CASCADE, related_name='+', null=True, blank=True) + based_on = models.ForeignKey('content.Content', models.SET_NULL, related_name='derived_content', null=True, blank=True) #on_delete=SET_NULL might cause a problem here + + +class ContentVersion(models.Model): + + container = models.ForeignKey(Content, models.CASCADE, related_name='versions') + title = models.CharField(max_length = 100) + content = models.TextField() + date = models.DateTimeField(auto_now_add=True) + comment = models.CharField(max_length = 100) + author_uri = models.CharField(max_length=256) diff --git a/content/forms.py b/content/forms.py new file mode 100644 index 00000000..fab0a106 --- /dev/null +++ b/content/forms.py @@ -0,0 +1,9 @@ +from django import forms +from content import utils + +class ContentForm(forms.Form): + title = forms.CharField(max_length=80) + content = forms.CharField(widget=forms.Textarea, required=False) + + def clean_content(self): + return utils.clean_user_content(self.cleaned_data['content']) diff --git a/content/migrations/0001_initial.py b/content/migrations/0001_initial.py new file mode 100644 index 00000000..3384df50 --- /dev/null +++ b/content/migrations/0001_initial.py @@ -0,0 +1,39 @@ +# Generated by Django 4.2.11 on 2025-07-24 07:26 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Content', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('based_on', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='derived_content', to='content.content')), + ], + ), + migrations.CreateModel( + name='ContentVersion', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=100)), + ('content', models.TextField()), + ('date', models.DateTimeField(auto_now_add=True)), + ('comment', models.CharField(max_length=100)), + ('author_uri', models.CharField(max_length=256)), + ('container', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='versions', to='content.content')), + ], + ), + migrations.AddField( + model_name='content', + name='latest', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='content.contentversion'), + ), + ] diff --git a/content/migrations/__init__.py b/content/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/content/models.py b/content/models.py new file mode 100644 index 00000000..d0731629 --- /dev/null +++ b/content/models.py @@ -0,0 +1,106 @@ +import json + +from content import db +from django.utils.text import slugify +from django.utils.translation import gettext as _ + +import logging +log = logging.getLogger(__name__) + + +def content_uri2id(content_uri): + return content_uri.strip('/').split('/')[-1] + + +def get_content(content_uri, fields=[]): + content_id = content_uri2id(content_uri) + try: + wrapper_db = db.Content.objects.get(id=content_id) + latest_db = wrapper_db.latest + except Exception as e: + #TODO + log.debug(e) + return None + + content = { + "id": wrapper_db.id, + "uri": "/uri/content/{0}".format(wrapper_db.id), + "title": latest_db.title, + "content": latest_db.content, + } + if "history" in fields: + content["history"] = [] + for version in wrapper_db.versions.sort_by("date"): + content['history'] += { + "date": version.date, + "author_uri": version.author_uri, + "title": version.title, + "comment": version.comment, + } + + return content + + +def create_content(title, content, author_uri): + #TODO check all required properties + container_db = db.Content() + container_db.save() + content_db = db.ContentVersion( + container=container_db, + title=title, + content=content, + author_uri = author_uri, + ) + #TODO if "comment" in content: + # content_db.comment = content["comment"] + content_db.save() + container_db.latest = content_db + container_db.save() + return get_content("/uri/content/{0}".format(container_db.id)) + + +def update_content( uri, title, content, author_uri ): + content_id = content_uri2id(uri) + try: + wrapper_db = db.Content.objects.get(id=content_id) + except Exception as e: + #TODO + log.debug(e) + return None + + content_db = db.ContentVersion( + container=wrapper_db, + title=title, + content=content, + author_uri = author_uri, + ) + #TODO if "comment" in content: + # content_db.comment = content["comment"] + content_db.save() + wrapper_db.latest = content_db + wrapper_db.save() + return get_content("/uri/content/{0}".format(wrapper_db.id)) + + +def clone_content(uri): + content_id = content_uri2id(uri) + try: + original_db = db.Content.objects.get(id=content_id) + except Exception as e: + log.debug(e) + raise + + container_db = db.Content(based_on=original_db) + container_db.save() + + content_db = db.ContentVersion( + container=container_db, + title=original_db.latest.title, + content=original_db.latest.content, + author_uri=original_db.latest.author_uri, + ) + content_db.save() + container_db.latest = content_db + container_db.save() + + return get_content("/uri/content/{0}".format(container_db.id)) diff --git a/content/templatetags/__init__.py b/content/templatetags/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/content/templatetags/content_tags.py b/content/templatetags/content_tags.py new file mode 100644 index 00000000..088b680e --- /dev/null +++ b/content/templatetags/content_tags.py @@ -0,0 +1,28 @@ +from django import template +from django.template.defaultfilters import stringfilter +from django.utils.safestring import mark_safe + +import markdown +import bleach +#from html5lib.tokenizer import HTMLTokenizer +import re + +register = template.Library() + +def add_target_blank(attrs, new=False): + attrs[(None, 'target')] = '_blank' + return attrs + +@register.filter(is_safe=True) +@stringfilter +def convert_content( markdown_text ): + html = markdown.markdown(markdown_text)#TODO , ['tables']) + html = bleach.linkify(html, + callbacks=bleach.linkifier.DEFAULT_CALLBACKS + [add_target_blank] + ) #TODO, tokenizer=HTMLTokenizer) + regex = re.compile(r'.*?', re.IGNORECASE|re.DOTALL) + f = lambda m: re.sub(r'&', '&', m.group(0)) + html = regex.sub(f, html) + return mark_safe(html) + +convert_content.is_safe = True diff --git a/content/tests.py b/content/tests.py new file mode 100644 index 00000000..0db9122d --- /dev/null +++ b/content/tests.py @@ -0,0 +1,55 @@ +from django.test import Client +from django.test import TestCase + +from custom_registration.models import create_user +from content import models as content_model + + +class CourseTests(TestCase): + + test_username = 'testuser' + test_email = 'test@mail.org' + test_password = 'testpass' + + test_username2 = 'bob' + test_email2 = 'bob@mail.org' + test_password2 = '13245' + + def setUp(self): + self.client = Client() + self.locale = 'en' + + self.user = create_user( + self.test_email, + self.test_username, + 'lastname', + self.test_password + ) + + self.user2 = create_user( + self.test_email2, + self.test_username2, + 'lastname2', + self.test_password2 + ) + + + def test_content_creation(self): + content = content_model.create_content('title', 'content', '/uri/users/bob') + content = content_model.get_content(content['uri']) + self.assertEquals(content, content) + + + def test_content_history(self): + content = content_model.create_content('title', 'content', '/uri/users/bob') + content_model.update_content( + content['uri'], 'New title', 'New content', '/uri/users/testuser' + ) + # TODO this doesn't test anything? + + + def test_clone_content(self): + content = content_model.create_content('title', 'content', '/uri/users/bob') + clone = content_model.clone_content(content['uri']) + for key in ['title', 'content']: + self.assertTrue(content[key], clone[key]) diff --git a/content/utils.py b/content/utils.py new file mode 100644 index 00000000..3263ff82 --- /dev/null +++ b/content/utils.py @@ -0,0 +1,11 @@ +import bleach + +def clean_user_content(content): + config = { + 'tags': ['table', 'tr', 'th', 'td', 'tbody', 'a', 'iframe'], + 'attributes': {'a': ['href'], 'iframe': ['src', 'width', 'height'] }, + 'strip': True + } + content = bleach.clean(content, **config) + #TODO ?? no idea why? + return content diff --git a/courses/__init__.py b/courses/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/courses/admin.py b/courses/admin.py new file mode 100644 index 00000000..15b14b45 --- /dev/null +++ b/courses/admin.py @@ -0,0 +1,6 @@ +from django.contrib import admin + +from .db import Course + +admin.site.register(Course) + diff --git a/courses/db.py b/courses/db.py new file mode 100644 index 00000000..060bd8ba --- /dev/null +++ b/courses/db.py @@ -0,0 +1,28 @@ +from django.db import models + +class Course(models.Model): + title = models.CharField(max_length=255) + short_title = models.CharField(max_length=20) + description = models.CharField(max_length=1000) + language = models.CharField(max_length=32) + image_uri = models.CharField(max_length=256) + creation_date = models.DateTimeField(auto_now_add=True) + draft = models.BooleanField(default=True) + archived = models.BooleanField(default=False) + deleted = models.BooleanField(default=False) + creator_uri = models.CharField(max_length=256) + based_on = models.ForeignKey('courses.Course', models.SET_NULL, related_name='derived_courses', blank=True, null=True) + + def __str__(self): + return self.title + + +class CourseContent(models.Model): + course = models.ForeignKey(Course, models.CASCADE, related_name='content') + content_uri = models.CharField(max_length=256) + index = models.PositiveIntegerField() + + +class CourseTags(models.Model): + course = models.ForeignKey(Course, models.CASCADE, related_name='tags') + tag = models.CharField(max_length=64) diff --git a/courses/decorators.py b/courses/decorators.py new file mode 100644 index 00000000..7a17dc4c --- /dev/null +++ b/courses/decorators.py @@ -0,0 +1,17 @@ +from django import http +from django.utils.translation import gettext as _ + +from courses import models as course_model + +def require_organizer( function ): + def check_organizer( *args, **kwargs ): + request = args[0] + course_id = kwargs.get('course_id') + course_uri = course_model.course_id2uri(course_id) + user_uri = "/uri/user/{0}".format(request.user.username) + organizer = course_model.is_organizer(course_uri, user_uri) + organizer |= request.user.is_superuser + if not organizer: + return http.HttpResponseForbidden(_("You need to be a course organizer.")) + return function(*args, **kwargs) + return check_organizer diff --git a/courses/forms.py b/courses/forms.py new file mode 100644 index 00000000..e6d62227 --- /dev/null +++ b/courses/forms.py @@ -0,0 +1,46 @@ +from django import forms +from django.conf import settings +from django.utils.translation import gettext_lazy as _ +from django.forms.widgets import RadioSelect + +import re + +class HashtagField(forms.CharField): + def clean(self, value): + if not value: + return super(HashtagField, self).clean(value) + if not re.match('^[#]*[a-z,A-Z][a-z,A-Z,0-9,_,-]*$', value): + raise forms.ValidationError(_("The hashtag must start with letter and contain only letters, digits, _ and -")) + return super(HashtagField, self).clean(value.strip('#')) + + +class CourseCreationForm(forms.Form): + title = forms.CharField() + hashtag = HashtagField(max_length=20) + description = forms.CharField(widget=forms.Textarea) + language = forms.ChoiceField(choices=settings.LANGUAGES) + + +class CourseUpdateForm(forms.Form): + title = forms.CharField(required=False) + hashtag = HashtagField(required=False, max_length=20) + description = forms.CharField(widget=forms.Textarea, required=False) + language = forms.ChoiceField(choices=settings.LANGUAGES, required=False) + + +class CourseImageForm(forms.Form): + image = forms.ImageField() + + +class CourseTagsForm(forms.Form): + tags = forms.CharField(max_length=256) + + +class CourseStatusForm(forms.Form): + STATUS_CHOICES = [ + ('draft', _('Draft'), ), + ('published', _('Published'), ), + ('archived', _('Archived'), ), + ] + + status = forms.ChoiceField(choices=STATUS_CHOICES, widget=RadioSelect) diff --git a/courses/migrations/0001_initial.py b/courses/migrations/0001_initial.py new file mode 100644 index 00000000..9f0e72f9 --- /dev/null +++ b/courses/migrations/0001_initial.py @@ -0,0 +1,84 @@ +# Generated by Django 4.2.11 on 2025-07-24 07:26 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Cohort', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('term', models.CharField(choices=[('ROLLING', 'Rolling'), ('FIXED', 'Fixed')], max_length=32)), + ('start_date', models.DateTimeField(blank=True, null=True)), + ('end_date', models.DateTimeField(blank=True, null=True)), + ('signup', models.CharField(choices=[('OPEN', 'Anyone can sign up'), ('MODERATED', 'Signups are moderated'), ('CLOSED', 'Signups are closed')], max_length=32)), + ], + ), + migrations.CreateModel( + name='Course', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=255)), + ('short_title', models.CharField(max_length=20)), + ('description', models.CharField(max_length=1000)), + ('language', models.CharField(max_length=32)), + ('image_uri', models.CharField(max_length=256)), + ('creation_date', models.DateTimeField(auto_now_add=True)), + ('draft', models.BooleanField(default=True)), + ('archived', models.BooleanField(default=False)), + ('deleted', models.BooleanField(default=False)), + ('creator_uri', models.CharField(max_length=256)), + ('based_on', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='derived_courses', to='courses.course')), + ], + ), + migrations.CreateModel( + name='CourseTags', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('tag', models.CharField(max_length=64)), + ('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tags', to='courses.course')), + ], + ), + migrations.CreateModel( + name='CourseContent', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('content_uri', models.CharField(max_length=256)), + ('index', models.PositiveIntegerField()), + ('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='content', to='courses.course')), + ], + ), + migrations.CreateModel( + name='CohortSignup', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('user_uri', models.CharField(max_length=256)), + ('role', models.CharField(choices=[('LEARNER', 'Learner'), ('ORGANIZER', 'Organizer')], max_length=10)), + ('signup_date', models.DateTimeField(auto_now_add=True)), + ('leave_date', models.DateTimeField(blank=True, null=True)), + ('cohort', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='signup_set', to='courses.cohort')), + ], + ), + migrations.CreateModel( + name='CohortComment', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('comment_uri', models.CharField(max_length=256)), + ('reference_uri', models.CharField(max_length=256)), + ('cohort', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comment_set', to='courses.cohort')), + ], + ), + migrations.AddField( + model_name='cohort', + name='course', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cohort_set', to='courses.course'), + ), + ] diff --git a/courses/migrations/0002_remove_cohortcomment_cohort_and_more.py b/courses/migrations/0002_remove_cohortcomment_cohort_and_more.py new file mode 100644 index 00000000..b5dfe21e --- /dev/null +++ b/courses/migrations/0002_remove_cohortcomment_cohort_and_more.py @@ -0,0 +1,30 @@ +# Generated by Django 4.2.11 on 2026-01-15 09:58 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('courses', '0001_initial'), + ] + + operations = [ + migrations.RemoveField( + model_name='cohortcomment', + name='cohort', + ), + migrations.RemoveField( + model_name='cohortsignup', + name='cohort', + ), + migrations.DeleteModel( + name='Cohort', + ), + migrations.DeleteModel( + name='CohortComment', + ), + migrations.DeleteModel( + name='CohortSignup', + ), + ] diff --git a/courses/migrations/__init__.py b/courses/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/courses/models.py b/courses/models.py new file mode 100644 index 00000000..82c23604 --- /dev/null +++ b/courses/models.py @@ -0,0 +1,341 @@ +import json +import datetime + +from django.utils.translation import gettext as _ +from django.conf import settings + +from django.urls import reverse + +from django.utils.text import slugify +from courses import db +from content import models as content_model +#from learn import models as learn_model +from media import models as media_model +#from notifications import models as notification_model + +from django.contrib.auth import get_user_model +# from users.models import User #NOTE: don't like this dep + +User = get_user_model() + + +import logging +log = logging.getLogger(__name__) + + +class ResourceNotFoundException(Exception): + pass + + +class ResourceDeletedException(Exception): + pass + + +class DataIntegrityException(Exception): + pass + + +def course_uri2id(course_uri): + return course_uri.strip('/').split('/')[-1] + + +def course_id2uri(course_id): + return '/uri/course/{0}/'.format(course_id) + + +def _get_course_db(course_uri): + course_id = course_uri2id(course_uri) + try: + course_db = db.Course.objects.get(id=course_id) + except: + raise ResourceNotFoundException + if course_db.deleted: + raise ResourceDeletedException + return course_db + + +def get_course(course_uri): + course_db = _get_course_db(course_uri) + course = { + "id": course_db.id, + "uri": "/uri/course/{0}".format(course_db.id), + "title": course_db.title, + "hashtag": course_db.short_title, + "slug": slugify(course_db.title), + "description": course_db.description, + "language": course_db.language, + "date_created": course_db.creation_date, + "author_uri": course_db.creator_uri, + } + + if course_db.based_on: + course['based_on_uri'] = "/uri/course/{0}".format(course_db.based_on.id) + + course["status"] = 'published' + if course_db.archived: + course["status"] = 'archived' + elif course_db.draft: + course["status"] = 'draft' + + if len(course_db.image_uri) > 0: + course["image_uri"] = course_db.image_uri + + content = get_course_content(course_uri) + if len(content) > 0: + course["about_uri"] = content[0]['uri'] + else: + log.error("missing about content") + raise DataIntegrityException + + course["content"] = content[1:] + return course + + +def get_courses(title=None, hashtag=None, language=None, organizer_uri=None, draft=None, archived=None): + results = db.Course.objects + #NOTE: could also take **kwargs and do results.filter(**kwargs) + filters = { 'deleted': False } + if title: + filters['title'] = title + if hashtag: + filters['short_title'] = hashtag + if language: + filters['language'] = language + if organizer_uri: + filters['creator_uri'] = organizer_uri + if draft != None: + filters['draft'] = draft + if archived != None: + filters['archived'] = archived + results = results.filter(**filters) + return [ get_course(course_id2uri(course_db.id)) for course_db in results ] + + +def create_course(title, hashtag, description, language, organizer_uri, based_on_uri=None): + course_db = db.Course( + title=title, + short_title=hashtag, + description=description, + language=language, + creator_uri=organizer_uri + ) + + if based_on_uri: + based_on = _get_course_db(based_on_uri) + course_db.based_on = based_on + + course_db.save() + + about = content_model.create_content( + **{"title": _("About"), "content": "", "author_uri": organizer_uri} + ) + add_course_content("/uri/course/{0}".format(course_db.id), about['uri']) + course = get_course("/uri/course/{0}".format(course_db.id)) + + return course + + +def clone_course(course_uri, organizer_uri): + original_course = get_course(course_uri) + new_course = create_course( + original_course['title'], + original_course['hashtag'], + original_course['description'], + original_course['language'], + organizer_uri, + course_uri + ) + + new_about = content_model.clone_content(original_course['about_uri']) + about_db = db.CourseContent.objects.get(content_uri=new_course['about_uri']) + about_db.content_uri = new_about['uri'] + about_db.save() + + for content in original_course['content']: + new_content = content_model.clone_content(content['uri']) + add_course_content(new_course['uri'], new_content['uri']) + + return get_course(new_course['uri']) + + +def update_course(course_uri, title=None, hashtag=None, description=None, language=None, image_uri=None): + # TODO + course_db = _get_course_db(course_uri) + if title: + course_db.title = title + if hashtag: + course_db.short_title = hashtag + if description: + course_db.description = description + if language: + course_db.language = language + if image_uri: + course_db.image_uri = image_uri + + course_db.save() + + return get_course(course_uri) + + +def publish_course(course_uri): + """ publish the course - also add course to the 'listed' list in the learn index """ + + course_db = _get_course_db(course_uri) + if not (course_db.draft or course_db.archived): + return get_course(course_uri) + + course_db.draft = False + course_db.archived = False + course_db.save() + + course = get_course(course_uri) + course_url = reverse( + 'courses_slug_redirect', + kwargs={'course_id': course["id"]} + ) + return course + + +def archive_course(course_uri): + """ mark a course as archived - this will also disable editing of the course """ + course_db = _get_course_db(course_uri) + if course_db.archived: + return get_course(course_uri) + + course_db.archived = True + course_db.save() + + course = get_course(course_uri) + course_url = reverse( + 'courses_slug_redirect', + kwargs={'course_id': course["id"]} + ) + + return course + + +def unpublish_course(course_uri): + course_db = _get_course_db(course_uri) + + if course_db.draft and not course_db.archived: + return get_course(course_uri) + + course_db.archived = False + course_db.draft = True + course_db.save() + + course = get_course(course_uri) + course_url = reverse( + 'courses_slug_redirect', + kwargs={'course_id': course["id"]} + ) + + return course + + +def delete_spam_course(course_uri): + """ Delete a course and remove listing from index """ + # TODO - this doesn't do anything special for spam, maybe rename the function + course_id = course_uri2id(course_uri) + course_db = db.Course.objects.get(id=course_id) + course_db.deleted = True + course_db.save() + + +def get_course_content(course_uri): + content = [] + course_id = course_uri2id(course_uri) + course_db = _get_course_db(course_uri) + for course_content_db in course_db.content.order_by('index'): + content_data = content_model.get_content(course_content_db.content_uri) + content += [{ + "id": content_data["id"], + "uri": content_data["uri"], + "title": content_data["title"], + "index": course_content_db.index, + }] + return content + + +def add_course_content(course_uri, content_uri): + course_id = course_uri2id(course_uri) + course_db = _get_course_db(course_uri) + next_index = 0 + try: + next_index = db.CourseContent.objects.filter(course = course_db).order_by('-index')[0].index + 1 + except: + # TODO + pass + course_content_db = db.CourseContent( + course=course_db, + content_uri=content_uri, + index=next_index + ) + course_content_db.save() + + +def remove_course_content(course_uri, content_uri): + course_id = course_uri2id(course_uri) + course_db = _get_course_db(course_uri) + course_content_db = course_db.content.get(content_uri=content_uri) + course_content_db.delete() + for content in course_db.content.filter(index__gt=course_content_db.index): + content.index = content.index - 1 + content.save() + + +def reorder_course_content(content_uri, direction): + course_content_db = None + try: + course_content_db = db.CourseContent.objects.get(content_uri=content_uri) + except: + raise ResourceNotFoundException + new_index = course_content_db.index + 1 + if direction == "UP": + new_index = course_content_db.index - 1 + if new_index < 1: + return + + try: + swap_course_content_db = db.CourseContent.objects.get( + course=course_content_db.course, index=new_index + ) + except: + #TODO + raise Exception("Internal error") + + swap_course_content_db.index = course_content_db.index + course_content_db.index = new_index + swap_course_content_db.save() + course_content_db.save() + + +def get_course_tags(course_uri): + course_db = _get_course_db(course_uri) + tags_db = db.CourseTags.objects.filter(course=course_db) + return tags_db.values_list('tag', flat=True) + + +def add_course_tags(course_uri, tags): + course_db = _get_course_db(course_uri) + + for tag in tags: + if not db.CourseTags.objects.filter(course=course_db, tag=tag).exists(): + tag_db = db.CourseTags(course=course_db, tag=tag) + tag_db.save() + + +def remove_course_tags(course_uri, tags): + course_db = _get_course_db(course_uri) + + for tag_db in db.CourseTags.objects.filter(course=course_db, tag__in=tags): + tag_db.delete() + + +def is_organizer(course_uri, user_uri): + try: + course_db = _get_course_db(course_uri) + except: + raise ResourceNotFoundException + + return course_db.creator_uri == user_uri diff --git a/courses/static/css/course.css b/courses/static/css/course.css new file mode 100644 index 00000000..043213ff --- /dev/null +++ b/courses/static/css/course.css @@ -0,0 +1,6 @@ +.row { + display: flex; +} +.main {a + flex: 1; +} diff --git a/courses/templates/courses/content.html b/courses/templates/courses/content.html new file mode 100644 index 00000000..c860acd8 --- /dev/null +++ b/courses/templates/courses/content.html @@ -0,0 +1,65 @@ +{% extends "courses/course_base.html" %} +{% load i18n %} + +{% comment %}{% load embed %}{% endcomment %} +{% load content_tags %} +{% comment %}{% load settings_tags %}{% endcomment %} + +{% block title %}{{course.title}} | {{content.title}}{% endblock %} +{% block bodyclasses %}course-content{% endblock%} + +{% block breadcrumbs %} +
  • + + {{course.title}} +
  • +
  • {{ content.title }}
  • +{% endblock %} + +{% block tab-pane-content %} +

    {{ content.title }}

    + {% comment %}
    {{content.content|convert_content|embed|safe}}
    {% endcomment %} +
    {{content.content|convert_content|safe}}
    + {% if can_edit %} +

    + + {% trans "edit" %} + +

    + {% endif %} + +
    + {% comment %} +

    {% trans "Comments" %}

    +
    + + + comments powered by Disqus + + {% endcomment %} + +{% endblock %} diff --git a/courses/templates/courses/content_form_buttons_snip.html b/courses/templates/courses/content_form_buttons_snip.html new file mode 100644 index 00000000..855a26b0 --- /dev/null +++ b/courses/templates/courses/content_form_buttons_snip.html @@ -0,0 +1,15 @@ +{% load i18n %} + + + +{% if next_url %} + {% trans 'Cancel' %} +{% else %} + {% if content %} + {% trans 'Cancel' %} + {% else %} + {% trans 'Cancel' %} + {% endif %} +{% endif %} + + diff --git a/courses/templates/courses/content_form_snip.html b/courses/templates/courses/content_form_snip.html new file mode 100644 index 00000000..d4cf2d01 --- /dev/null +++ b/courses/templates/courses/content_form_snip.html @@ -0,0 +1,63 @@ +{% load i18n %} +{% load static %} + + +
    + {% csrf_token %} + + {% include "courses/content_form_buttons_snip.html" %} +
    + +
    +
    +
    +

    + + {{ form.title.errors }} + +

    + +

    + + {{ form.content.errors }} +

    + +

    + {% blocktrans %}You can use markdown for your content. + For some help on using markdown, look + here + {% endblocktrans %} +

    +

    +
    +
    +
    + +
    +
    +
    + {% comment %} + {% include 'learn/markdown_cheatsheet.html' %} + {% endcomment %} +
    + {% include "courses/content_form_buttons_snip.html" %} + +
    +
    +
    + + + diff --git a/courses/templates/courses/content_image_form.html b/courses/templates/courses/content_image_form.html new file mode 100644 index 00000000..0e65da36 --- /dev/null +++ b/courses/templates/courses/content_image_form.html @@ -0,0 +1,76 @@ +{% extends "base.html" %} +{% load i18n %} + + +{% block title %}Upload a course{% endblock %} +{% block bodyclasses %}course-create{% endblock%} + +{% block css %} + + +{% endblock %} + +{% block links %} +{% endblock %} + +{% block breadcrumbs_title %} +

    + {{course.title}}

    +{% endblock %} + +{% block breadcrumbs %} +
  • + + {{course.title}} +
  • +{% endblock %} + + +{% block content %} + +
    + + +
    +
    + {% csrf_token %} + {{ form }} + +
    +
    +{% endblock %} + +{% block js %} + + + + + + + +{% endblock %} diff --git a/courses/templates/courses/course.html b/courses/templates/courses/course.html new file mode 100644 index 00000000..045b3c2e --- /dev/null +++ b/courses/templates/courses/course.html @@ -0,0 +1,24 @@ +{% extends "courses/course_base.html" %} + +{% load i18n %} +{% load content_tags %} + +{% block tab-pane-content %} + {% if about.content|length == 0 %} + {% if can_edit %} +
    +

    {% blocktrans %}You can add some information here by editing this page!{% endblocktrans %} +

    +
    + {% endif %} + {% else %} +
    {{about.content|convert_content|safe}}
    + {% endif %} +

    + {% if can_edit %} +

    + {% trans "edit" %} +

    + {% endif %} +

    +{% endblock %} diff --git a/courses/templates/courses/course_admin_content.html b/courses/templates/courses/course_admin_content.html new file mode 100644 index 00000000..2202bdfd --- /dev/null +++ b/courses/templates/courses/course_admin_content.html @@ -0,0 +1,37 @@ +{% extends "courses/course_base.html" %} + +{% load i18n %} + +{% block tab-pane-content %} + {% if course.content|length %} + + {% for task in course.content %} + + + {% if organizer %} + + + {% endif %} + + {% if organizer %} + + + {% endif %} + + {% endfor %} +
    {{ task.title }}{% if not forloop.first %}{% endif %}{% if not forloop.last %}{% endif %}
    + {% csrf_token %} + +
    + +
    + + {% if organizer %} + {% trans "Add more content" %} + {% endif %} + {% else %} + {% if organizer %} +

    {% blocktrans %}This course doesn't have any content yet. {% endblocktrans %}Click here to add some.

    + {% endif %} + {% endif %} +{% endblock %} diff --git a/courses/templates/courses/course_base.html b/courses/templates/courses/course_base.html new file mode 100644 index 00000000..5cf7b251 --- /dev/null +++ b/courses/templates/courses/course_base.html @@ -0,0 +1,93 @@ +{% extends "base.html" %} + +{% load i18n %} +{% load static %} + +{% block page_title %}{{course.title}}{% endblock %} +{% block bodyclasses %}course-landing{% endblock%} + +{% block css %} + +{% endblock %} + +{% block links %} +{% if project %} + +{% endif %} +{% endblock %} + +{% block breadcrumbs_title %} +

    {{course.title}}

    +{% endblock %} + +{% block breadcrumbs %} +
  • {{course.title}}
  • +{% endblock %} + +{% block content %} + +
    + + + +
    + + + + + + + + + {% for metaitem in meta_data.items %} + + {% endfor %} + + {% if educational_alignment %} + + {% for metaitem in educational_alignment.items %} + + {% endfor %} + + {% endif %} + +
    + +
    +
    + {% block tab-pane-content %} + {% endblock %} +
    +
    +
    +
    +
    + +{% endblock %} diff --git a/courses/templates/courses/course_delete_confirmation.html b/courses/templates/courses/course_delete_confirmation.html new file mode 100644 index 00000000..6c1c6b34 --- /dev/null +++ b/courses/templates/courses/course_delete_confirmation.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} + + +{% load i18n %} + +{% block title %}{{ _('Confirm course deletion') }}{% endblock %} +{% block body %} + +{% endblock %} diff --git a/courses/templates/courses/course_settings.html b/courses/templates/courses/course_settings.html new file mode 100644 index 00000000..54a3dd10 --- /dev/null +++ b/courses/templates/courses/course_settings.html @@ -0,0 +1,99 @@ +{% extends "courses/course_base.html" %} + +{% load i18n %} + +{% block tab-pane-content %} + {% if course.status == 'archived' %} +
    +

    {% blocktrans %}This course is currently archived. Before you can make changes you need to change the course to published or draft.{% endblocktrans %}

    +
    + {% csrf_token %} +

    + {{ status_form.status }} +

    +
    +
    + {% else %} +
    +
    + {% csrf_token %} +

    + + {{ update_form.title }} +

    + +
    +
    +
    +
    +
    + {% csrf_token %} +

    + + {{ update_form.hashtag }} +

    + +
    +
    + +
    + +
    +
    + {% csrf_token %} +

    {{image_form.image}}

    +

    +
    +
    + +
    +
    +
    + {% csrf_token %} + + {{ update_form.description }} +

    +
    +
    +
    +
    +
    + {% csrf_token %} +

    + {{ status_form.status }} +

    +
    +
    +
    +
    +
    +
    +
    + {% csrf_token %} + + {{ tags_form.tags }} +

    +
    +
    +
    +
    +
    +
    + {% csrf_token %} + + {{ update_form.language }} +

    +
    +
    +
    +
    + {% endif %} +{% endblock %} + +{% block js %} + +{% endblock %} diff --git a/courses/templates/courses/course_sidebar.html b/courses/templates/courses/course_sidebar.html new file mode 100644 index 00000000..0ca81be9 --- /dev/null +++ b/courses/templates/courses/course_sidebar.html @@ -0,0 +1,59 @@ +{% load i18n %} + + + diff --git a/courses/templates/courses/create_content.html b/courses/templates/courses/create_content.html new file mode 100644 index 00000000..22b6c35c --- /dev/null +++ b/courses/templates/courses/create_content.html @@ -0,0 +1,72 @@ +{% extends "base.html" %} +{% load i18n %} + + +{% block title %}Create a new course{% endblock %} +{% block bodyclasses %}course-create{% endblock%} + +{% block css %} + + +{% endblock %} + +{% block links %} +{% endblock %} + +{% block breadcrumbs_title %} +

    + {{course.title}}

    +{% endblock %} + +{% block breadcrumbs %} +
  • + + {{course.title}} +
  • +{% endblock %} + + +{% block content %} + +
    + + +
    + {% include "courses/content_form_snip.html" %} +
    +{% endblock %} + +{% block js %} + + + + + + + +{% endblock %} diff --git a/courses/templates/courses/create_course.html b/courses/templates/courses/create_course.html new file mode 100644 index 00000000..9fa0c62c --- /dev/null +++ b/courses/templates/courses/create_course.html @@ -0,0 +1,72 @@ +{% extends "base.html" %} +{% load i18n %} + +{% block title %}Create a new course{% endblock %} +{% block bodyclasses %}course-create{% endblock%} + +{% block css %} + +{% endblock %} + +{% block links %} +{% endblock %} + +{% block breadcrumbs_title %} +

    {% trans "Create a new course" %}

    +{% endblock %} + +{% block breadcrumbs %} +{% endblock %} + +{% block content %} +
    +
    + {% blocktrans %} +

    Ready to create a course?

    +

    First, give your course a title--we recommend something zippy, snappy and attention-grabby.

    +

    Next, create a "hashtag" or handle for your course.

    +

    Give a few more details in the "description" field, hit the button and voilĂ ! You've created a P2PU course!

    +

    Need further guidance on how to make a stellar experience? Check out the "Create a Course" materials!

    +

    Be sure to have a look at our featured courses for good examples.

    +

    If you'd like help or feedback on your course, drop a line to the P2PU Community forum and say 'Hi'.

    + {% endblocktrans %} +
    + +
    +
    + {% csrf_token %} +

    + + {{form.title.errors}} + + +

    +

    + + {{form.hashtag.errors}} + + + +

    +

    + + {{form.description.errors}} + +

    +

    + + {{ form.language }} +

    + +
    +
    +
    +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/courses/templates/courses/edit_content.html b/courses/templates/courses/edit_content.html new file mode 100644 index 00000000..e96a0744 --- /dev/null +++ b/courses/templates/courses/edit_content.html @@ -0,0 +1,70 @@ +{% extends "base.html" %} +{% load i18n %} + + +{% block title %}{{content.title}}{% endblock %} +{% block bodyclasses %}course-content{% endblock%} + +{% block css %} + + +{% endblock %} + +{% block breadcrumbs_title %} +

    + {{course.title}}

    +{% endblock %} + +{% block breadcrumbs %} +
  • + + {{course.title}} +
  • + +{% endblock %} + +{% block content %} +
    + + +
    +

    {{ content.title }}

    + {% include "courses/content_form_snip.html" %} +
    +
    +{% endblock %} + +{% block script %} + + + + + + + + +{% endblock %} diff --git a/courses/templates/courses/preview_content_snip.html b/courses/templates/courses/preview_content_snip.html new file mode 100644 index 00000000..5c2e9b7c --- /dev/null +++ b/courses/templates/courses/preview_content_snip.html @@ -0,0 +1,3 @@ +{% load embed %} +{% load content2_tags %} +{{content|convert_content|embed|safe}} diff --git a/courses/tests.py b/courses/tests.py new file mode 100644 index 00000000..579e0889 --- /dev/null +++ b/courses/tests.py @@ -0,0 +1,156 @@ +from django.test import Client +from django.test import TestCase + +from custom_registration.models import create_user +from courses import models as course_model +from content import models as content_model + +from unittest.mock import patch + + +class CourseTests(TestCase): + + test_username = 'testuser' + test_email = 'test@mail.org' + test_password = 'testpass' + + test_username2 = 'bob' + test_email2 = 'bob@mail.org' + test_password2 = '13245' + + test_title = "A test course" + test_hashtag = "hashtag" + test_description = "This is only a test." + + def setUp(self): + self.client = Client() + self.locale = 'en' + + self.user = create_user( + self.test_email, + self.test_username, + 'lastname', + self.test_password + ) + + self.user2 = create_user( + self.test_email2, + self.test_username2, + 'lastname2', + self.test_password2 + ) + + self.course = course_model.create_course( + **{ + "title": self.test_title, + "hashtag": self.test_hashtag, + "description": self.test_description, + "language": "en", + "organizer_uri": '/uri/users/testuser', + } + ) + content_model.update_content( + self.course['about_uri'], + 'About', + 'This is the about content', + '/uri/users/testuser' + ) + + def test_course_creation(self): + course = course_model.create_course( + **{ + "title": "A test course", + "hashtag": "ATC-1", + "description": "This course is all about ABC", + "language": "en", + "organizer_uri": '/uri/user/testuser' + } + ) + + self.assertTrue(not course == None) + + # test that about content was created + about = content_model.get_content(course['about_uri']) + self.assertTrue(not about == None) + self.assertEqual(about['title'], "About") + + self.assertTrue( + course_model.is_organizer(course['uri'], '/uri/user/testuser') + ) + + def test_course_get(self): + course = course_model.get_course(self.course['uri']) + self.assertTrue('id' in course) + self.assertTrue('uri' in course) + self.assertTrue('title' in course) + self.assertTrue('hashtag' in course) + self.assertTrue('slug' in course) + self.assertTrue('description' in course) + self.assertTrue('language' in course) + self.assertTrue('date_created' in course) + self.assertTrue('author_uri' in course) + self.assertTrue('status' in course) + self.assertTrue('about_uri' in course) + self.assertTrue('content' in course) + + + def test_clone_course(self): + clone = course_model.clone_course(self.course['uri'], '/uri/user/bob/') + for key in ['title', 'hashtag', 'description', 'language']: + self.assertEqual(clone[key], self.course[key]) + self.assertIn('based_on_uri', clone) + + about = content_model.get_content(self.course['about_uri']) + clone_about = content_model.get_content(clone['about_uri']) + self.assertEquals(about['content'], clone_about['content']) + + self.assertEqual(len(clone['content']), len(self.course['content'])) + for i in range(len(clone['content'])): + self.assertEqual(clone['content'][i]['title'], self.course['content'][i]['title']) + self.assertEqual(clone['content'][i]['content'], self.course['content'][i]['content']) + + def test_delete_spam_course(self): + course = course_model.create_course( + **{ + "title": "A test course", + "hashtag": "ATC-1", + "description": "This course is all about ABC", + "language": "en", + "organizer_uri": '/uri/user/testuser' + } + ) + course_model.delete_spam_course(course['uri']) + with self.assertRaises(course_model.ResourceDeletedException): + course_model.get_course(course['uri']) + + def test_get_courses(self): + user = create_user('99@example.com', 'testuser99', 'last', 'password') + + course = course_model.create_course( + **{ + "title": "A test course", + "hashtag": "ATC-1", + "description": "This course is all about ABC", + "language": "en", + "organizer_uri": '/uri/user/testuser' + } + ) + + course = course_model.create_course( + **{ + "title": "A unique test course", + "hashtag": "AUTC-1", + "description": "This course is all about ABC", + "language": "en", + "organizer_uri": '/uri/user/testuser99' + } + ) + + + # get course by title + courses = course_model.get_courses(title="A unique test course") + self.assertEquals(len(courses), 1) + + # get course by user uri + courses = course_model.get_courses(organizer_uri="/uri/user/testuser99") + self.assertEquals(len(courses), 1) diff --git a/courses/urls.py b/courses/urls.py new file mode 100644 index 00000000..4910c5bb --- /dev/null +++ b/courses/urls.py @@ -0,0 +1,83 @@ +from django.urls import re_path + +from courses import views + +urlpatterns = [ + re_path(r'^create/$', views.create_course, + name='courses_create'), + + re_path(r'^(?P[\d]+)/clone/$', + views.clone_course, + name='courses_clone'), + + re_path(r'^(?P[\d]+)/$', + views.course_slug_redirect, + name='courses_slug_redirect'), + + re_path(r'^(?P[\d]+)/admin_content/$', + views.course_admin_content, + name='courses_admin_content'), + + re_path(r'^(?P[\d]+)/settings/$', + views.course_settings, + name='courses_settings'), + + re_path(r'^(?P[\d]+)/delete_spam/$', + views.delete_spam, + name='courses_delete_spam'), + + re_path(r'^(?P[\d]+)/upload_image/$', + views.course_image, + name='courses_image'), + + # view to upload image for content + re_path( + r'^(?P[\d]+)/content/upload_image/$', + views.course_content_image_upload, + name='courses_content_image_upload' + ), + + re_path(r'^(?P[\d]+)/change_status/$', + views.course_change_status, + name='courses_change_status'), + + re_path(r'^(?P[\d]+)/update/(?P[\w_]+)/$', + views.course_update_attribute, + name='courses_update_attribute'), + + re_path(r'^(?P[\d]+)/update_tags/$', + views.course_update_tags, + name='courses_update_tags'), + + re_path(r'^(?P[\d]+)/(?P[\w-]+)/$', + views.show_course, + name='courses_show'), + + re_path(r'^(?P[\d]+)/content/create/$', + views.create_content, + name='courses_create_content'), + + re_path(r'content/preview/$', + views.preview_content, + name='courses_preview_content'), + + re_path(r'^(?P[\d]+)/content/(?P[\d]+)/$', + views.show_content, + name='courses_content_show'), + + re_path(r'^(?P[\d]+)/content/(?P[\d]+)/edit/$', + views.edit_content, + name='courses_content_edit'), + + re_path(r'^(?P[\d]+)/content/(?P[\d]+)/remove/$', + views.remove_content, + name='courses_content_remove'), + + re_path(r'^(?P[\d]+)/content/(?P[\d]+)/up/$', + views.move_content_up, + name='courses_content_up'), + + re_path(r'^(?P[\d]+)/content/(?P[\d]+)/down/$', + views.move_content_down, + name='courses_content_down'), +] diff --git a/courses/views.py b/courses/views.py new file mode 100644 index 00000000..e36533c0 --- /dev/null +++ b/courses/views.py @@ -0,0 +1,438 @@ +import logging +import unicodecsv + +from django import http +from django.shortcuts import render, get_object_or_404 +from django.template.loader import render_to_string +from django.template import RequestContext +from django.utils.translation import gettext as _ +from django.utils.translation import get_language +import json +from django.views.decorators.http import require_http_methods +from django.conf import settings + +from django.urls import reverse +from django.contrib.auth.decorators import login_required +from django.contrib.auth import get_user_model +User = get_user_model() + +from django.contrib import messages + +from courses import models as course_model +from courses.forms import CourseCreationForm +from courses.forms import CourseUpdateForm +from courses.forms import CourseImageForm +from courses.forms import CourseStatusForm +from courses.forms import CourseTagsForm +from courses.decorators import require_organizer + +from content import models as content_model +from content.forms import ContentForm + +from media import models as media_model + + +log = logging.getLogger(__name__) + + +def _get_course_or_404( course_uri ): + try: + course = course_model.get_course(course_uri) + except course_model.ResourceNotFoundException as e: + raise http.Http404 + return course + + +def _populate_course_context( request, course_id, context ): + course_uri = course_model.course_id2uri(course_id) + course = _get_course_or_404(course_uri) + course['author'] = course['author_uri'].strip('/').split('/')[-1] + context['course'] = course + context['course_url'] = reverse('courses_show', + kwargs={'course_id': course['id'], 'slug': course['slug']} + ) + if 'image_uri' in course: + context['course']['image'] = media_model.get_image(course['image_uri']) + + user_uri = u"/uri/user/{0}".format(request.user.username) + context['organizer'] = course_model.is_organizer(course_uri, user_uri) + context['organizer'] |= request.user.is_superuser + context['admin'] = request.user.is_superuser + context['can_edit'] = context['organizer'] and not course['status'] == 'archived' + context['trusted_user'] = request.user.has_perm('users.trusted_user') + + if 'based_on_uri' in course: + course['based_on'] = course_model.get_course(course['based_on_uri']) + + return context + + +@login_required +def create_course( request ): + if request.method == "POST": + form = CourseCreationForm(request.POST) + if form.is_valid(): + user = request.user + user_uri = u"/uri/user/{0}".format(user.username) + course = { + 'title': form.cleaned_data.get('title'), + 'hashtag': form.cleaned_data.get('hashtag'), + 'description': form.cleaned_data.get('description'), + 'language': form.cleaned_data.get('language'), + 'organizer_uri': user_uri + } + course = course_model.create_course(**course) + redirect_url = reverse('courses_show', + kwargs={'course_id': course['id'], 'slug': course['slug']} + ) + return http.HttpResponseRedirect(redirect_url) + else: + form = CourseCreationForm(initial={'language': get_language()}) + + context = { 'form': form } + return render(request, 'courses/create_course.html', context) + + +@require_organizer +def clone_course( request, course_id ): + course_uri = course_model.course_id2uri(course_id) + user_uri = u"/uri/user/{0}".format(request.user.username) + course = course_model.clone_course(course_uri, user_uri) + return course_slug_redirect(request, course['id']) + + +def course_slug_redirect( request, course_id ): + course_uri = course_model.course_id2uri(course_id) + course = _get_course_or_404(course_uri) + redirect_url = reverse('courses_show', + kwargs={'course_id': course_id, 'slug': course['slug']}) + return http.HttpResponseRedirect(redirect_url) + + +def show_course( request, course_id, slug=None ): + course_uri = course_model.course_id2uri(course_id) + course = _get_course_or_404(course_uri) + + if slug != course['slug']: + return course_slug_redirect( request, course_id) + + context = _populate_course_context(request, course_id, {}) + context['about_active'] = True + + if context['organizer']: + context['update_form'] = CourseUpdateForm(course) + + context['about'] = content_model.get_content(course['about_uri']) + + return render(request, 'courses/course.html', context) + + +@login_required +@require_organizer +def course_admin_content( request, course_id ): + course_uri = course_model.course_id2uri(course_id) + course = _get_course_or_404(course_uri) + + context = { } + context = _populate_course_context(request, course_id, context) + context['content_active'] = True + + return render( + request, + 'courses/course_admin_content.html', + context + ) + + +@login_required +@require_organizer +def course_settings( request, course_id ): + course_uri = course_model.course_id2uri(course_id) + course = _get_course_or_404(course_uri) + + context = { } + context = _populate_course_context(request, course_id, context) + + context['update_form'] = CourseUpdateForm(course) + context['image_form'] = CourseImageForm() + context['status_form'] = CourseStatusForm(course) + tags = ", ".join(course_model.get_course_tags(course_uri)) + context['tags_form'] = CourseTagsForm({'tags': tags}) + context['settings_active'] = True + + return render( + request, + 'courses/course_settings.html', + context + ) + + +@login_required +@require_http_methods(['POST']) +@require_organizer +def course_image( request, course_id ): + course_uri = course_model.course_id2uri(course_id) + user_uri = u"/uri/user/{0}".format(request.user.username) + image_form = CourseImageForm(request.POST, request.FILES) + if image_form.is_valid(): + image_file = request.FILES['image'] + image = media_model.upload_image(image_file, course_uri) + course_model.update_course( + course_uri=course_uri, + image_uri=image['uri'], + ) + else: + messages.error(request, _("Could not upload image")) + redirect_url = reverse('courses_settings', kwargs={'course_id': course_id}) + return http.HttpResponseRedirect(redirect_url) + + +@login_required +@require_organizer +def course_content_image_upload( request, course_id ): + course_uri = course_model.course_id2uri(course_id) + user_uri = u"/uri/user/{0}".format(request.user.username) + if request.method == 'POST': + image_form = CourseImageForm(request.POST, request.FILES) + if image_form.is_valid(): + image_file = request.FILES['image'] + image = media_model.upload_image(image_file, course_uri) + if request.headers.get('accept') == 'application/json' or request.headers.get('x-requested-with') == 'XMLHttpRequest': + return http.JsonResponse({ + 'status': 'success', + 'image_uri': image['uri'], + 'image_url': image['url'], + }) + redirect_url = reverse('courses_show', + kwargs={'course_id': course_id, 'slug': 'ermm'} + ) + + return http.HttpResponseRedirect(redirect_url) + else: + messages.error(request, _("Could not upload image")) + else: + image_form = CourseImageForm() + + context = _populate_course_context(request, course_id, {}) + context["form"] = image_form + + return render( + request, + 'courses/content_image_form.html', + context + ) + + +@login_required +@require_http_methods(['POST']) +@require_organizer +def course_change_status( request, course_id ): + user_uri = u"/uri/user/{0}".format(request.user.username) + course_uri = course_model.course_id2uri(course_id) + form = CourseStatusForm(request.POST) + if form.is_valid(): + status = form.cleaned_data['status'] + if status == 'draft': + course = course_model.unpublish_course(course_uri) + elif status == 'published': + course = course_model.publish_course(course_uri) + elif status == 'archived': + course = course_model.archive_course(course_uri) + messages.success(request, _('Course status updated.')) + + redirect_url = reverse('courses_settings', kwargs={'course_id': course_id}) + return http.HttpResponseRedirect(redirect_url) + + +@login_required +@require_http_methods(['POST']) +@require_organizer +def course_update_attribute( request, course_id, attribute): + course_uri = course_model.course_id2uri(course_id) + form = CourseUpdateForm(request.POST) + if form.is_valid(): + kwargs = { attribute: form.cleaned_data[attribute] } + course_model.update_course( course_uri, **kwargs ) + else: + messages.error(request, _("Could not update {0}.".format(attribute))) + redirect_url = reverse('courses_settings', kwargs={'course_id': course_id}) + return http.HttpResponseRedirect(redirect_url) + + +@login_required +@require_http_methods(['POST']) +@require_organizer +def course_update_tags( request, course_id ): + course_uri = course_model.course_id2uri(course_id) + form = CourseTagsForm(request.POST) + if form.is_valid(): + tags = [tag.strip() for tag in form.cleaned_data['tags'].split(',')] + course_model.remove_course_tags( + course_uri, course_model.get_course_tags(course_uri) + ) + course_model.add_course_tags(course_uri, tags) + messages.success( request, _("Course tags successfully updated") ) + + redirect_url = reverse('courses_settings', kwargs={'course_id': course_id}) + return http.HttpResponseRedirect(redirect_url) + + +def show_content( request, course_id, content_id): + content_uri = u'/uri/content/{0}'.format(content_id) + user_uri = u"/uri/user/{0}".format(request.user.username) + context = _populate_course_context(request, course_id, {}) + + if not any( c['uri'] == content_uri for c in context['course']['content']): + raise http.Http404 + + content = content_model.get_content(content_uri) + context['content'] = content + context['content_active'] = True + + context['form'] = ContentForm(content) + return render( + request, + 'courses/content.html', + context + ) + + +@login_required +@require_organizer +def create_content( request, course_id ): + course_uri = course_model.course_id2uri(course_id) + course = _get_course_or_404(course_uri) + if request.method == "POST": + form = ContentForm(request.POST) + if form.is_valid(): + user = request.user + user_uri = u"/uri/user/{0}".format(user.username) + content_data = { + 'title': form.cleaned_data.get('title'), + 'content': form.cleaned_data.get('content'), + 'author_uri': user_uri, + } + content = content_model.create_content(**content_data) + course_model.add_course_content(course['uri'], content['uri']) + + redirect_url = reverse('courses_show', + kwargs={'course_id': course['id'], 'slug': course['slug']} + ) + return http.HttpResponseRedirect(redirect_url) + else: + form = ContentForm() + + context = { 'form': form } + context = _populate_course_context(request, course_id, context) + + return render( + request, + 'courses/create_content.html', + context + ) + + +@login_required +@require_organizer +def edit_content( request, course_id, content_id ): + course_uri = course_model.course_id2uri(course_id) + course = _get_course_or_404(course_uri) + content = content_model.get_content("/uri/content/{0}".format(content_id)) + + if request.method == "POST": + form = ContentForm(request.POST) + if form.is_valid(): + content_data = { + 'title': form.cleaned_data.get('title'), + 'content': form.cleaned_data.get('content'), + } + user = request.user + user_uri = u"/uri/user/{0}".format(user.username) + content = content_model.update_content( + content['uri'], content_data['title'], + content_data['content'], user_uri + ) + + redirect_url = reverse('courses_content_show', + kwargs={'course_id': course_id, 'content_id': content_id} + ) + return http.HttpResponseRedirect(redirect_url) + else: + form = ContentForm(initial=content) + + context = { + 'form': form, + 'content': content, + } + context = _populate_course_context(request, course_id, context) + return render( + request, + 'courses/edit_content.html', + context + ) + + +@login_required +@require_http_methods(['POST']) +def preview_content( request ): + content = request.POST.get('content') + from content import utils + content = utils.clean_user_content(content) + content = render_to_string("courses/preview_content_snip.html", + {'content':content }) + return http.HttpResponse(content, mimetype="application/json") + + +@login_required +@require_organizer +def remove_content( request, course_id, content_id ): + course_uri = course_model.course_id2uri(course_id) + content_uri = "/uri/content/{0}".format(content_id) + course_model.remove_course_content(course_uri, content_uri) + redirect_url = reverse('courses_admin_content', kwargs={'course_id': course_id}) + return http.HttpResponseRedirect(redirect_url) + + +@login_required +@require_organizer +def move_content_up( request, course_id, content_id ): + try: + course_model.reorder_course_content( + "/uri/content/{0}".format(content_id), "UP" + ) + except: + messages.error(request, _("Could not move content up!")) + redirect_url = reverse('courses_admin_content', kwargs={'course_id': course_id}) + return http.HttpResponseRedirect(redirect_url) + + +@login_required +@require_organizer +def move_content_down( request, course_id, content_id ): + try: + course_model.reorder_course_content( + "/uri/content/{0}".format(content_id), "DOWN" + ) + except: + messages.error(request, _("Could not move content down!")) + redirect_url = reverse('courses_admin_content', kwargs={'course_id': course_id}) + return http.HttpResponseRedirect(redirect_url) + + +@login_required +@require_organizer +def delete_spam(request, course_id): + course_uri = course_model.course_id2uri(course_id) + course = _get_course_or_404(course_uri) + if request.method == "POST": + course_model.delete_spam_course(course_uri) + #TODO display splash message to indicate success + return http.HttpResponseRedirect(reverse('home')) + + context = { } + context = _populate_course_context(request, course_id, context) + return render( + request, + 'courses/course_delete_confirmation.html', + context + ) diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 1589bb3b..9f03ffd6 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -1,9 +1,9 @@ version: '3' services: postgres: - image: postgres:11 + image: postgres:15 volumes: - - ./compose/postgres:/var/lib/postgresql/data + - ./compose/postgres-15:/var/lib/postgresql/data email: image: djfarrelly/maildev ports: diff --git a/frontend/components/learning_circle_form/FinalizeSection.jsx b/frontend/components/learning_circle_form/FinalizeSection.jsx index a9104d56..84f08cf6 100644 --- a/frontend/components/learning_circle_form/FinalizeSection.jsx +++ b/frontend/components/learning_circle_form/FinalizeSection.jsx @@ -1,5 +1,54 @@ import React, { useState } from 'react' -import { TextareaWithLabel, SelectWithLabel } from 'p2pu-components' +import { TextareaWithLabel, SelectWithLabel, InputWithLabel, SwitchWithLabels } from 'p2pu-components' + +const SignupLimit = (props) => { + + let maxSignups = null; + let defaultLimit = 20; + if (window.maxSignups){ + maxSignups = window.maxSignups + defaultLimit = maxSignups + } + + const handleSwitch = ({enableLimit}) => { + if (enableLimit) { + props.updateFormData({'signup_limit': defaultLimit}) + } else { + props.updateFormData({'signup_limit': undefined}) + } + } + + return ( + <> + { !maxSignups && + + } + { (props.learningCircle.signup_limit !== undefined || maxSignups) && + + } + + + ) +} const FinalizeSection = (props) => { @@ -54,6 +103,9 @@ const FinalizeSection = (props) => { id={'id_facilitator_concerns'} errorMessage={props.errors.facilitator_concerns} /> +
    Object.keys(errors).filter(e => tab.indexOf(e) != -1)); diff --git a/frontend/components/learning_circle_form/PlaceInput.jsx b/frontend/components/learning_circle_form/PlaceInput.jsx index bc8c82b1..c2583329 100644 --- a/frontend/components/learning_circle_form/PlaceInput.jsx +++ b/frontend/components/learning_circle_form/PlaceInput.jsx @@ -4,6 +4,7 @@ import InputWrapper from 'p2pu-components/dist/InputFields/InputWrapper' import AsyncCreatableSelect from 'react-select/async-creatable'; import InputWithLabel from 'p2pu-components/dist/InputFields/InputWithLabel' +// TODO move to p2pu-components const CountryInput = props => { const { handleChange, name, value, disabled, selectClasses, ...rest } = props; @@ -14,7 +15,7 @@ const CountryInput = props => { } const searchPlaces = (query) => { - const url = "/api/places/search/country/"; + const url = "/api/places/search/country/"; // TODO pass URL as a prop return axios({ url, method: 'GET', diff --git a/frontend/components/manage/meeting-attendance-input.jsx b/frontend/components/manage/meeting-attendance-input.jsx new file mode 100644 index 00000000..6d1d4107 --- /dev/null +++ b/frontend/components/manage/meeting-attendance-input.jsx @@ -0,0 +1,74 @@ +import React, { useState } from 'react' + +const SummarizedAttendanceInput = ({value, onChange}) => +
    + +
    + onChange({'attendance': e.target.value})} id="id_attendance" /> +
    +
    ; + + +const GranularAttendanceInput = ({value, onChange, learners}) => { + var parsedValue = JSON.parse(value || '{}') + + const handleCheck = userId => { + if (parsedValue[userId]) { + delete parsedValue[userId] + } else { + parsedValue[userId] = true + } + onChange({"granular_attendance": JSON.stringify(parsedValue)}) + } + + return ( +
    + + { + learners.map(user => ( +
    + handleCheck(user.id)}/> + +
    + )) + } +
    + ); +} + +const AttendanceInput = ({formData, onChange}) => { + + const [summarizedInput, setSummarizedInput] = useState( + !(formData.granular_attendance && formData.granular_attendance.length > 2) + ) + + const toggle = (e) => { + e.preventDefault() + setSummarizedInput(!summarizedInput) + } + + return ( + <> + { summarizedInput && ( + <> + +

    (Record per learner attendance)

    + + )} + { !summarizedInput && ( + <> + +

    (Record summarized attendance)

    + + )} + + ); +} + +export default AttendanceInput; diff --git a/frontend/components/meeting-feedback.jsx b/frontend/components/meeting-feedback.jsx index 157d037d..2adfa8ff 100644 --- a/frontend/components/meeting-feedback.jsx +++ b/frontend/components/meeting-feedback.jsx @@ -2,24 +2,16 @@ import React, { useState } from 'react' import RatingInput from './manage/meeting-rating-input'; import MeetingReflectionInput from './manage/meeting-reflection-input'; +import AttendanceInput from './manage/meeting-attendance-input'; import DelayedPostForm from './manage/delayed-post-form'; -const AttendanceInput = ({value, onChange}) => -
    - -
    - onChange({'attendance': e.target.value})} id="id_attendance" /> -
    -
    ; - - const MeetingFeedbackForm = props => { const {formData, updateForm} = props; return (
    - + ) @@ -37,6 +29,7 @@ const MeetingFeedback = props => { let initialFormValues = { rating: props.rating, attendance: props.attendance, + granular_attendance: props.granularAttendance, reflection: props.reflection, study_group_meeting: props.meetingId, }; @@ -70,4 +63,3 @@ const MeetingFeedback = props => { }; export default MeetingFeedback; - diff --git a/frontend/components/participant-dash.jsx b/frontend/components/participant-dash.jsx index 712a31df..a70a48bf 100644 --- a/frontend/components/participant-dash.jsx +++ b/frontend/components/participant-dash.jsx @@ -28,7 +28,7 @@ const RsvpForm = ({formData, updateForm}) => { onChange={handleChange} checked={formData.rsvp} /> - +
    { onChange={handleChange} checked={formData.rsvp === false} /> - +
    ); @@ -217,7 +217,6 @@ const CuCreditPrompt = props => { return (
    -

    Participants in this learning circle have the option to earn college credit from College Unbound. In order to earn credit, you must complete a learning journal while participating in the learning circle. For more information, refer to the FAQs.

    @@ -226,6 +225,41 @@ const CuCreditPrompt = props => { } +const DDDeviceForm = props => { + const done = props.device_agreement_completed + return ( +
    +
    +
    + +
    Device Agreement
    + { done && +
    +

    Thank you for completing your device agreement form.

    +

    Update details

    +
    + } + { !done && +
    +

    This form serves as your authorization to receive a device from Digital Skills Detroit. Please complete within your first 2 Digital Skills Detroit sessions to be considered for your device upon program completion.

    +

    Complete device agreement

    +
    + } +
    +
    + ); + +} + const ParticipantDash = props => { const {meetings, messages, signup_message, survey_link, survey_completed} = props; @@ -258,6 +292,7 @@ const ParticipantDash = props => {
    { props.cu_credit && } + { props.device_agreement_url && } { items.map( (item, i) => { return diff --git a/gdoc/README.md b/gdoc/README.md new file mode 100644 index 00000000..0b6bbb89 --- /dev/null +++ b/gdoc/README.md @@ -0,0 +1,6 @@ +A python script to + +1. download a google document +2. convert it to markdown +3. download included images +4. upload to learning circle course content diff --git a/gdoc/docker-compose.yml b/gdoc/docker-compose.yml new file mode 100644 index 00000000..48d3a6fe --- /dev/null +++ b/gdoc/docker-compose.yml @@ -0,0 +1,11 @@ +services: + python: + image: python:3.11 + volumes: + - .:/opt/app + ports: + - 8080:8080 + network_mode: host + user: 1000:1000 + working_dir: /opt/app + command: bash -c "python3 -m venv venv && /opt/app/venv/bin/pip install -r requirements.txt" diff --git a/gdoc/gdoc2ciab.py b/gdoc/gdoc2ciab.py new file mode 100644 index 00000000..3e493216 --- /dev/null +++ b/gdoc/gdoc2ciab.py @@ -0,0 +1,286 @@ +from __future__ import print_function +import pickle +import os.path +import os +import logging +import yaml +import re +import requests +import shutil +from pprint import pprint + +from google.auth.transport.requests import Request +from google.oauth2.credentials import Credentials +from google_auth_oauthlib.flow import InstalledAppFlow +from googleapiclient.discovery import build +from googleapiclient.errors import HttpError + + +logger = logging.getLogger(__name__) + +# If modifying these scopes, delete the file token.pickle. +SCOPES = ['https://www.googleapis.com/auth/documents.readonly'] + + +# The ID of the google doc with the course content. +#DOCUMENT_ID = '1hfsuDUvoRqMDP5Hl0Xv5G73tPFSxtVM7HTJWxLfDMVo' +DOCUMENT_ID = '1MPULtjQmBonAh7rZmivJFslk21KisFR8UeVKRXF2zN8' + +NEW_TAB_LINKS = [ + r'https://community.p2pu.org/t/introduce-yourself/1571/', + r'https://docs.google.com/presentation/d/1_s0FFtAPG8MHxL8yRFrdxaI22obFrX_ZsONz-sIZJSY/edit#slide=id.g3c793ae459_0_0', + r'https://www.p2pu.org/en/courses/', + r'https://learningcircles.p2pu.org/en/accounts/register.*?', + r'https://learningcircles.p2pu.org.*?', + r'https://community.p2pu.org/t/what-topics-are-missing/2786', +] + + +def get_doc(document_id): + """Shows basic usage of the Docs API. + Prints the title of a sample document. + """ + creds = None + # The file token.pickle stores the user's access and refresh tokens, and is + # created automatically when the authorization flow completes for the first + # time. + if os.path.exists('token.pickle'): + with open('token.pickle', 'rb') as token: + creds = pickle.load(token) + # If there are no (valid) credentials available, let the user log in. + if not creds or not creds.valid: + if creds and creds.expired and creds.refresh_token: + creds.refresh(Request()) + else: + flow = InstalledAppFlow.from_client_secrets_file('credentials.json', SCOPES) + creds = flow.run_local_server() + ## TODO can I specify the address to bind to? + # Save the credentials for the next run + with open('token.pickle', 'wb') as token: + pickle.dump(creds, token) + + service = build('docs', 'v1', credentials=creds) + + # Retrieve the documents contents from the Docs service. + document = service.documents().get(documentId=document_id).execute() + #print('The title of the document is: {}'.format(document.get('title'))) + return document + + +# Image formats to embed +IMAGE_FORMATS = ['jpeg', 'jpg', 'png', 'svg'] + + +def smart_link(text, url, embed=False): + #print('' + url + (' embed' if embed else '') ) + if any(map(lambda x: re.match(x, url), NEW_TAB_LINKS)): + return f'{text}' + if not embed: + return f"[{text}]({url})" + + if url.split('.')[-1].lower() in IMAGE_FORMATS: + return f"![{text}]({url})" + + return f"[{text}]({url})" + + +def convert_to_course_outline(document): + # document is formatted doc > body > content + # content is a list of structural elements, for now handle paragraph and table + # https://developers.google.com/workspace/docs/api/reference/rest/v1/documents#structuralelement + + content = document.get('body').get('content') + #pprint(document.get('inlineObjects')) + # TODO this filters out anything that isn't a paragraph! + content = filter(lambda se: 'paragraph' in se, content) + modules = [] # [{'title': '', 'md': ''}] + intro = '' + for se in content: + paragraph = se['paragraph'] + #pprint(se) + page_break = any([True for e in paragraph['elements'] if 'pageBreak' in e]) + if page_break: + # Stop processing the document after the first page break + break + text = '' + elements = paragraph['elements'] + #elements = filter(lambda e: e.get('textRun','').strip('\n') == '', elements) + for eidx, element in enumerate(elements): + if 'textRun' in element: + textRun = element.get('textRun') + textContent = textRun['content'].strip('\n') + bold = textRun['textStyle'].get('bold', False) + italic = textRun['textStyle'].get('italic', False) + link = textRun['textStyle'].get('link', {}).get('url') + if not textContent: + if any([bold, italic, link]): + logger.warning(f'Ignoring empty textRun. bold: {bold} italic: {italic} link: {link}') + continue + if bold: + textContent = f'**{textContent}**' + if italic: + textContent = f'*{textContent}*' + if link: + text += smart_link(textContent, link, embed=True) + else: + text += textContent + elif 'inlineObjectElement' in element: + inlineObjectElement = element.get('inlineObjectElement') + inlineObject = document.get('inlineObjects').get(inlineObjectElement.get('inlineObjectId')) + imageUri = inlineObject.get('inlineObjectProperties', {}).get('embeddedObject', {}).get('imageProperties', {}).get('contentUri') + if imageUri: + print('Found image, downloading the sucker!') + r = requests.get(imageUri, stream=True) + print(r.headers['content-type']) + ext = r.headers.get('content-type', 'image/jpg').split('/')[-1] + image_path = f'assets/uploads/{inlineObject["objectId"]}.{ext}' + if r.status_code == 200: + with open(image_path, 'wb') as f: + r.raw.decode_content = True + shutil.copyfileobj(r.raw, f) + image_id = inlineObject["objectId"] + # add text with reference style link + text += f'![alt todo][{image_id}]' + # add image to current module + if len(modules) > 0: + module = modules[-1] + images = module.get('images', {}) + images[image_id] = { + "image_id": image_id, + "image_path": image_path, + } # TODO alt text, title? + module['images'] = images + + else: + print(f'Could not download image!! {r.status_code}') + + + if paragraph.get('paragraphStyle',{}).get('namedStyleType') == 'HEADING_2': + text = '# ' + text + '\n' + + if paragraph.get('paragraphStyle',{}).get('namedStyleType') == 'HEADING_3': + text = '## ' + text + + if paragraph.get('paragraphStyle',{}).get('namedStyleType') == 'HEADING_4': + text = '### ' + text + + if 'bullet' in paragraph: + bullet = paragraph['bullet'] + nesting_level = bullet.get('nestingLevel', 0) + list_properties = document.get('lists', {}).get(bullet.get('listId'), {}).get('listProperties',{}) + style = list_properties.get('nestingLevels')[nesting_level] + if 'glyphType' in style and not style['glyphType'] == 'GLYPH_TYPE_UNSPECIFIED': + glyph = '1.' + else: + glyph = '-' + + text = ' '*nesting_level + glyph + ' ' + text + + # Start a new module when encountering HEADING 1 (H1) + if paragraph.get('paragraphStyle',{}).get('namedStyleType') == 'HEADING_1': + modules += [{'title': text, 'md': ''}] + continue + + if len(modules) == 0: + intro += text + '\n' + continue + + module = modules[-1] + module['md'] += text + '\n' + + course_outline = { + 'modules': modules, + 'title': document.get('title'), + 'intro': intro, + } + return course_outline + + +def write_module(title, text, index): + slug = title.replace(' ', '-').lower() + path = os.path.join('_guide', f'{index+1:02}_{slug}.md') + + + with open(path, 'w') as f: + f.write('---\n') + f.write(f'title: "{title}"\n') + f.write('---\n') + f.write(text) + + +def write_index(text_md): + with open('./index.md', 'w') as f: + f.write('---\n') + f.write(f'layout: index\n') + f.write('---\n') + f.write(text_md) + + +def write_course(course_outline): + for index, module in enumerate(course_outline.get('modules')): + write_module(module['title'], module['md'], index) + + +import requests + + +def upload_course(course_id, course_outline): + # Create a session object + with requests.Session() as s: + LOGIN_URL = 'http://localhost:8000/en/accounts/login/' + r1 = s.get(LOGIN_URL) + csrf_token = s.cookies['csrftoken'] + login_data = {'username': 'dirk', 'password': 'password', 'csrfmiddlewaretoken': csrf_token} + s.post(LOGIN_URL, data=login_data) + r = s.get('http://localhost:8000/en/accounts/fe/whoami/') + print(r.text) + + course_url = 'http://localhost:8000/en/courses/1/' + for index, module in enumerate(course_outline.get('modules')): + # upload_module(module['title'], module['md'], module['images'], index) + module_md = module['md'] + image_upload_url = f'http://localhost:8000/en/courses/1/content/upload_image/' + for image in module.get('images', {}).values(): + # upload image + image_id = image['image_id'] + image_path = image['image_path'] + with open(image_path, 'rb') as image_file: + files = {'image': image_file} + data = { + 'csrfmiddlewaretoken': s.cookies['csrftoken'], + } + headers = { + 'Accept': 'application/json' + } + response = s.post(image_upload_url, files=files, data=data, headers=headers) + + if response.status_code == 200: + print("File and form data uploaded successfully.") + # print(response.json()) + # add reference style link for image to bottom + image_url = response.json().get('image_url') + module_md += f'\n[{image_id}]: {image_url}' + #module_md += f'\n[{image_id}]: {image_url} "{image_title}"' + else: + print(response.text) + # Create new module in course + content_create_url = 'http://localhost:8000/en/courses/1/content/create/' + data = { + 'csrfmiddlewaretoken': s.cookies['csrftoken'], + 'title': module['title'], + 'content': module_md, + } + response = s.post(content_create_url, data=data) + if response.status_code == 200: + print("Module created") + else: + print(response.text) + + + + +if __name__ == '__main__': + doc = get_doc(DOCUMENT_ID) + course_outline = convert_to_course_outline(doc) + upload_course(1, course_outline) + #write_course(course_outline) diff --git a/gdoc/requirements.txt b/gdoc/requirements.txt new file mode 100644 index 00000000..17b0d44a --- /dev/null +++ b/gdoc/requirements.txt @@ -0,0 +1,26 @@ +cachetools==3.1.0 +certifi==2019.3.9 +chardet==3.0.4 +charset-normalizer==3.3.2 +google-api-core==2.19.1 +google-api-python-client==2.142.0 +google-auth==2.34.0 +google-auth-httplib2==0.2.0 +google-auth-oauthlib==0.3.0 +googleapis-common-protos==1.63.2 +httplib2==0.22.0 +idna==2.8 +oauthlib==3.0.1 +proto-plus==1.24.0 +protobuf==5.27.3 +pyasn1==0.4.5 +pyasn1-modules==0.2.4 +pyparsing==3.1.4 +PyYAML==5.1 +requests==2.21.0 +requests-oauthlib==1.2.0 +rsa==4.0 +six==1.12.0 +uritemplate==4.1.1 +urllib3==1.24.1 + diff --git a/learnwithpeople/settings.py b/learnwithpeople/settings.py index f087db36..dd46266c 100644 --- a/learnwithpeople/settings.py +++ b/learnwithpeople/settings.py @@ -64,6 +64,9 @@ 'community_calendar', 'client_logging', 'contact', + 'content', + 'courses', + 'media', ] MIDDLEWARE = [ @@ -320,7 +323,7 @@ }, 'console': { 'class': 'logging.StreamHandler', - 'level': 'DEBUG' if DEBUG else 'WARNING', + 'level': 'WARNING' if DEBUG else 'WARNING', }, }, 'root': { @@ -348,7 +351,6 @@ USE_DEPRECATED_PYTZ = True #### Backup config #### - BACKUP_DIR = os.environ.get('BACKUP_DIR', '/tmp') # Directory where backups will be stored locally BACKUP_AWS_ACCESS_KEY_ID = os.environ.get('BACKUP_AWS_ACCESS_KEY_ID') # AWS key with access to backup bucket BACKUP_AWS_SECRET_ACCESS_KEY = os.environ.get('BACKUP_AWS_SECRET_ACCESS_KEY') # AWS secret for above key diff --git a/learnwithpeople/urls.py b/learnwithpeople/urls.py index 2758bed4..618827d6 100644 --- a/learnwithpeople/urls.py +++ b/learnwithpeople/urls.py @@ -12,6 +12,7 @@ re_path(r'^discourse/', include('discourse_sso.urls')), re_path(r'^surveys/', include('surveys.urls')), re_path(r'^community_calendar/', include('community_calendar.urls')), + re_path(r'^courses/', include('courses.urls')), re_path(r'^', include('studygroups.urls')) ) diff --git a/media/__init__.py b/media/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/media/admin.py b/media/admin.py new file mode 100644 index 00000000..9b4d321c --- /dev/null +++ b/media/admin.py @@ -0,0 +1,6 @@ +from django.contrib import admin + +from .db import Image + +admin.site.register(Image) + diff --git a/media/db.py b/media/db.py new file mode 100644 index 00000000..9ae17327 --- /dev/null +++ b/media/db.py @@ -0,0 +1,10 @@ +from django.db import models + + +class Image(models.Model): + + image_file = models.FileField(upload_to="uploads/images") + uploader_uri = models.CharField(max_length=256) + + def __str__(self): + return self.image_file diff --git a/media/migrations/0001_initial.py b/media/migrations/0001_initial.py new file mode 100644 index 00000000..ce601704 --- /dev/null +++ b/media/migrations/0001_initial.py @@ -0,0 +1,22 @@ +# Generated by Django 2.2 on 2019-04-09 05:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Image', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('image_file', models.FileField(upload_to='uploads/images')), + ('uploader_uri', models.CharField(max_length=256)), + ], + ), + ] diff --git a/media/migrations/__init__.py b/media/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/media/models.py b/media/models.py new file mode 100644 index 00000000..d9973244 --- /dev/null +++ b/media/models.py @@ -0,0 +1,23 @@ +from media import db + +def upload_image(image_file, uploader_uri): + image_db = db.Image(image_file=image_file, uploader_uri=uploader_uri) + try: + image_db.save() + except: + return None + return get_image("/uri/media/image/{0}".format(image_db.id)) + + +def get_image(image_uri): + image_id = image_uri.strip('/').split('/')[-1] + image_db = None + try: + image_db = db.Image.objects.get(id=image_id) + except: + return None + image = { + "uri": "/uri/media/image/{0}".format(image_db.id), + "url": image_db.image_file.url, #this needs to be https! + } + return image diff --git a/p2pu-theme b/p2pu-theme index d3aac8f5..0fd3b28c 160000 --- a/p2pu-theme +++ b/p2pu-theme @@ -1 +1 @@ -Subproject commit d3aac8f5330ba8324a956a71ab567811d82e8f44 +Subproject commit 0fd3b28cc18e564fb29a055d51a7f75900368da9 diff --git a/static/images/icons/device-white.svg b/static/images/icons/device-white.svg new file mode 100644 index 00000000..b05af819 --- /dev/null +++ b/static/images/icons/device-white.svg @@ -0,0 +1,188 @@ + + + +image/svg+xml diff --git a/static/sass/_lc-manage.scss b/static/sass/_lc-manage.scss index f6c18ce0..f9f62fd1 100644 --- a/static/sass/_lc-manage.scss +++ b/static/sass/_lc-manage.scss @@ -195,6 +195,18 @@ button.card-collapse-toggle { background: url("../images/icons/credit-white.svg") center center / 0.8em 0.8em no-repeat, $p2pu-blue; } +.lc-timeline .item.device-form.todo .icon::before{ + @include timeline-item; + content: ''; + background: url("../images/icons/device-white.svg") center center / 0.8em 0.8em no-repeat, $p2pu-yellow; +} + + +.lc-timeline .item.device-form .icon::before{ + @include timeline-item; + content: ''; + background: url("../images/icons/device-white.svg") center center / 0.8em 0.8em no-repeat, $p2pu-blue; +} @@ -253,6 +265,24 @@ button.card-collapse-toggle { } } + +.device-agreements { + padding: 1em; + background: $gray-light; + + ul { + margin: 0; + } + + li.pending::before { + @include rsvp-li; + content: ''; + background: url("../images/icons/clock.svg") left center / 1em 1em no-repeat; + } + +} + + .card-collapse-toggle { position: absolute; top: 20px; diff --git a/static/sass/_learningcircle.scss b/static/sass/_learningcircle.scss index 33ef3acf..54256a88 100644 --- a/static/sass/_learningcircle.scss +++ b/static/sass/_learningcircle.scss @@ -20,3 +20,7 @@ .rm-facilitator-warning { margin-top: 20px; } + +.page-content.scroll_top { + padding-top: 0; +} diff --git a/studygroups/admin.py b/studygroups/admin.py index 6286918c..9d49c2a3 100644 --- a/studygroups/admin.py +++ b/studygroups/admin.py @@ -16,6 +16,7 @@ from studygroups.models import TeamInvitation from studygroups.models import Announcement from studygroups.models import FacilitatorGuide +from studygroups.models import DeviceAllocation class ApplicationInline(admin.TabularInline): @@ -173,6 +174,11 @@ class MeetingAdmin(admin.ModelAdmin): raw_id_fields = ['study_group'] exclude = [] +class DeviceAllocationAdmin(admin.ModelAdmin): + list_display = ['user', 'amount', 'start_date', 'cutoff_date'] + raw_id_fields = ['user'] + exclude = [] + admin.site.register(Course, CourseAdmin) admin.site.register(CourseList, CourseListAdmin) @@ -186,3 +192,4 @@ class MeetingAdmin(admin.ModelAdmin): admin.site.register(Profile, ProfileAdmin) admin.site.register(Announcement) admin.site.register(FacilitatorGuide, FacilitatorGuideAdmin) +admin.site.register(DeviceAllocation, DeviceAllocationAdmin) diff --git a/studygroups/forms.py b/studygroups/forms.py index 845ecb03..62a9a755 100644 --- a/studygroups/forms.py +++ b/studygroups/forms.py @@ -498,3 +498,96 @@ class Meta: 'intro_text': TinyMCE() } + +class DeviceAgreementForm(forms.Form): + """ Form for Digital Detroit project device distribution """ + GENDER_CHOICES = [ + ('', 'Select...'), + ('male', 'Male'), + ('female', 'Female'), + ('non-binary', 'Non-binary'), + ('prefer-not-to-say', 'Prefer not to say'), + ('other', 'Other'), + ] + + CONSENT_CHOICES = [ + ('no', 'I do not consent to being contacted by the Detroit Housing Network to learn more about additional housing resources and opportunities that may benefit me.'), + ('yes', 'I consent to being contacted by the Detroit Housing Network to learn more about additional housing resources and opportunities that may benefit me.'), + ] + + # Learner Contact Information + first_name = forms.CharField( + max_length=100, + required=True, + widget=forms.TextInput(attrs={'class': 'form-control'}) + ) + + last_name = forms.CharField( + max_length=100, + required=True, + widget=forms.TextInput(attrs={'class': 'form-control'}) + ) + + date_of_birth = forms.DateField( + required=True, + widget=forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}) + ) + + phone_number = PhoneNumberField( + max_length=15, + required=True, + region="US", + widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': '(123) 456-7890'}) + ) + + email_address = forms.EmailField( + required=True, + widget=forms.EmailInput(attrs={'class': 'form-control'}) + ) + + home_address = forms.CharField( + required=True, + widget=forms.Textarea(attrs={'class': 'form-control', 'rows': 2}) + ) + + # Demographic Information (Optional) + gender = forms.ChoiceField( + choices=GENDER_CHOICES, + required=False, + widget=forms.Select(attrs={'class': 'form-select'}) + ) + + # Consent + housing_network_consent = forms.ChoiceField( + choices=CONSENT_CHOICES, + required=True, + widget=forms.RadioSelect(attrs={'class': 'form-check-input'}), + label="" + ) + + # Disclosures and Privacy Policy + disclosure_certification = forms.BooleanField( + required=True, + label='By submitting your information you are certifying that all information is correct to the best of your knowledge and that you reviewed the Disclosures and Detroit Housing Network (DHN) Privacy Policy.', + widget=forms.CheckboxInput(attrs={'class': 'form-check-input'}) + ) + + # Learner Signature + signature_acknowledgment = forms.BooleanField( + required=True, + label='I have read and understood the terms of this agreement.', + widget=forms.CheckboxInput(attrs={'class': 'form-check-input'}) + ) + + signature = forms.CharField( + max_length=200, + required=True, + widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Type your full name'}) + ) + + signature_date = forms.DateField( + required=True, + label='Date', + widget=forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}) + ) + diff --git a/studygroups/migrations/0172_alter_course_topic_guides_alter_courselist_courses.py b/studygroups/migrations/0172_alter_course_topic_guides_alter_courselist_courses.py new file mode 100644 index 00000000..3c09bd30 --- /dev/null +++ b/studygroups/migrations/0172_alter_course_topic_guides_alter_courselist_courses.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.11 on 2025-07-24 07:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('studygroups', '0171_courselist'), + ] + + operations = [ + migrations.AlterField( + model_name='course', + name='topic_guides', + field=models.ManyToManyField(blank=True, to='studygroups.topicguide'), + ), + migrations.AlterField( + model_name='courselist', + name='courses', + field=models.ManyToManyField(blank=True, to='studygroups.course'), + ), + ] diff --git a/studygroups/migrations/0172_studygroup_signup_limit_alter_course_topic_guides_and_more.py b/studygroups/migrations/0172_studygroup_signup_limit_alter_course_topic_guides_and_more.py new file mode 100644 index 00000000..7f269ff9 --- /dev/null +++ b/studygroups/migrations/0172_studygroup_signup_limit_alter_course_topic_guides_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2.11 on 2025-12-12 14:04 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('studygroups', '0171_courselist'), + ] + + operations = [ + migrations.AddField( + model_name='studygroup', + name='signup_limit', + field=models.SmallIntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='course', + name='topic_guides', + field=models.ManyToManyField(blank=True, to='studygroups.topicguide'), + ), + migrations.AlterField( + model_name='courselist', + name='courses', + field=models.ManyToManyField(blank=True, to='studygroups.course'), + ), + ] diff --git a/studygroups/migrations/0173_studygroup_course_content.py b/studygroups/migrations/0173_studygroup_course_content.py new file mode 100644 index 00000000..bba239f1 --- /dev/null +++ b/studygroups/migrations/0173_studygroup_course_content.py @@ -0,0 +1,20 @@ +# Generated by Django 4.2.11 on 2025-11-27 08:45 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('courses', '0001_initial'), + ('studygroups', '0172_alter_course_topic_guides_alter_courselist_courses'), + ] + + operations = [ + migrations.AddField( + model_name='studygroup', + name='course_content', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='courses.course'), + ), + ] diff --git a/studygroups/migrations/0174_course_course_content.py b/studygroups/migrations/0174_course_course_content.py new file mode 100644 index 00000000..2fef6a01 --- /dev/null +++ b/studygroups/migrations/0174_course_course_content.py @@ -0,0 +1,20 @@ +# Generated by Django 4.2.11 on 2026-01-15 09:25 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('courses', '0001_initial'), + ('studygroups', '0173_studygroup_course_content'), + ] + + operations = [ + migrations.AddField( + model_name='course', + name='course_content', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='courses.course'), + ), + ] diff --git a/studygroups/migrations/0175_merge_20260115_1437.py b/studygroups/migrations/0175_merge_20260115_1437.py new file mode 100644 index 00000000..7fd13cef --- /dev/null +++ b/studygroups/migrations/0175_merge_20260115_1437.py @@ -0,0 +1,14 @@ +# Generated by Django 4.2.11 on 2026-01-15 14:37 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('studygroups', '0172_studygroup_signup_limit_alter_course_topic_guides_and_more'), + ('studygroups', '0174_course_course_content'), + ] + + operations = [ + ] diff --git a/studygroups/migrations/0176_alter_studygroup_course_content.py b/studygroups/migrations/0176_alter_studygroup_course_content.py new file mode 100644 index 00000000..f47930ba --- /dev/null +++ b/studygroups/migrations/0176_alter_studygroup_course_content.py @@ -0,0 +1,20 @@ +# Generated by Django 4.2.11 on 2026-01-16 09:22 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('courses', '0002_remove_cohortcomment_cohort_and_more'), + ('studygroups', '0175_merge_20260115_1437'), + ] + + operations = [ + migrations.AlterField( + model_name='studygroup', + name='course_content', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='courses.course'), + ), + ] diff --git a/studygroups/migrations/0177_alter_course_course_content.py b/studygroups/migrations/0177_alter_course_course_content.py new file mode 100644 index 00000000..6b98f830 --- /dev/null +++ b/studygroups/migrations/0177_alter_course_course_content.py @@ -0,0 +1,20 @@ +# Generated by Django 4.2.11 on 2026-01-16 09:26 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('courses', '0002_remove_cohortcomment_cohort_and_more'), + ('studygroups', '0176_alter_studygroup_course_content'), + ] + + operations = [ + migrations.AlterField( + model_name='course', + name='course_content', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='courses.course'), + ), + ] diff --git a/studygroups/migrations/0178_feedback_granular_attendance.py b/studygroups/migrations/0178_feedback_granular_attendance.py new file mode 100644 index 00000000..7d2feee2 --- /dev/null +++ b/studygroups/migrations/0178_feedback_granular_attendance.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.11 on 2026-01-16 09:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('studygroups', '0177_alter_course_course_content'), + ] + + operations = [ + migrations.AddField( + model_name='feedback', + name='granular_attendance', + field=models.JSONField(default=dict), + ), + ] diff --git a/studygroups/migrations/0179_deviceallocation.py b/studygroups/migrations/0179_deviceallocation.py new file mode 100644 index 00000000..09920192 --- /dev/null +++ b/studygroups/migrations/0179_deviceallocation.py @@ -0,0 +1,26 @@ +# Generated by Django 4.2.11 on 2026-01-30 13:13 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('studygroups', '0178_feedback_granular_attendance'), + ] + + operations = [ + migrations.CreateModel( + name='DeviceAllocation', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('start_date', models.DateField()), + ('cutoff_date', models.DateField()), + ('amount', models.PositiveIntegerField(default=0)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/studygroups/models/__init__.py b/studygroups/models/__init__.py index d0738667..fab4391d 100644 --- a/studygroups/models/__init__.py +++ b/studygroups/models/__init__.py @@ -27,6 +27,7 @@ from .learningcircle import Rsvp from .learningcircle import Feedback from .facilitator_guide import FacilitatorGuide +from .device_allocation import DeviceAllocation import datetime import pytz diff --git a/studygroups/models/course.py b/studygroups/models/course.py index 09af51e9..b0cb4fc2 100644 --- a/studygroups/models/course.py +++ b/studygroups/models/course.py @@ -56,6 +56,8 @@ class Course(LifeTimeTrackingModel): rating_step_counts = models.TextField(default="{}") # JSON value # TODO discourse_topic_url = models.URLField(blank=True) + course_content = models.ForeignKey('courses.Course', on_delete=models.SET_NULL, blank=True, null=True) + def __str__(self): return self.title diff --git a/studygroups/models/device_allocation.py b/studygroups/models/device_allocation.py new file mode 100644 index 00000000..ed7c88c1 --- /dev/null +++ b/studygroups/models/device_allocation.py @@ -0,0 +1,57 @@ +# coding=utf-8 +from django.contrib.auth.models import User + +from django.db import models + +from studygroups.models import Facilitator +from studygroups.models import StudyGroup +import logging + +logger = logging.getLogger(__name__) + + +# NOTE: the current method is probably not suited to manage multiple overlapping device allocations. That is most likely fine for the current use case. But if the use case is expanded in the future, requirements should be clearly defined and the implementation should be adapted accordingly + + +class DeviceAllocation(models.Model): + """ date range applies to start date """ + user = models.ForeignKey(User, on_delete=models.CASCADE) + start_date = models.DateField() + cutoff_date = models.DateField() + amount = models.PositiveIntegerField(default=0) + + def __str__(self): + return f'{self.user.first_name} - {self.amount} device(s)' + + +def check_user_device_allocation(user, date): + """ check number of devices available to a facilitator """ + + device_allocations = DeviceAllocation.objects.filter( + user=user, + start_date__lte=date, + cutoff_date__gte=date + ) + + if device_allocations.count() == 0: + logger.warning('No active DeviceAllocation for user') + return 0 + + if device_allocations.count() > 1: + logger.warning('Multiple overlapping DeviceAllocations for user') + + # should just be one device allocation + device_allocation = device_allocations.first() + + # Find any learning circles in the device allocation time period + study_groups = StudyGroup.objects.active().filter( + facilitator__user=user, + start_date__gte=device_allocation.start_date, + start_date__lt=device_allocation.cutoff_date, + ) + + used_allocation = sum( + filter(lambda x: x is not None, [lc.signup_limit for lc in study_groups]) + ) + + return max(device_allocation.amount - used_allocation, 0) diff --git a/studygroups/models/learningcircle.py b/studygroups/models/learningcircle.py index ed2e1180..428871f4 100644 --- a/studygroups/models/learningcircle.py +++ b/studygroups/models/learningcircle.py @@ -57,6 +57,7 @@ def published(self): class StudyGroup(LifeTimeTrackingModel): name = models.CharField(max_length=128, blank=True) course = models.ForeignKey(Course, on_delete=models.CASCADE) + course_content = models.ForeignKey('courses.Course', on_delete=models.SET_NULL, blank=True, null=True) description = BleachField(max_length=2000, blank=True, allowed_tags=settings.TINYMCE_DEFAULT_CONFIG.get('valid_elements', '').split(','), allowed_attributes={'a': ['href', 'title', 'rel', 'target']}) course_description = BleachField(max_length=2000, blank=True, allowed_tags=settings.TINYMCE_DEFAULT_CONFIG.get('valid_elements', '').split(','), allowed_attributes={'a': ['href', 'title', 'rel', 'target']}) venue_name = models.CharField(max_length=256) @@ -79,6 +80,7 @@ class StudyGroup(LifeTimeTrackingModel): duration = models.PositiveIntegerField(default=90) # meeting duration in minutes timezone = models.CharField(max_length=128) signup_open = models.BooleanField(default=True) + signup_limit = models.SmallIntegerField(blank=True, null=True) draft = models.BooleanField(default=True) members_only = models.BooleanField(default=False) image = models.ImageField(blank=True) @@ -211,6 +213,28 @@ def reminders(self): def weeks(self): return (self.end_date - self.start_date).days//7 + 1 + + @property + def at_capacity(self): + if self.signup_limit is None: + return False + signup_count = self.application_set.active().count() + return signup_count >= self.signup_limit + + + # check if learning circle meets requirements for devices + def show_device_agreement(self): + def conditions(): + yield self.team + yield self.team.page_slug == 'digital-detroit' + yield self.start_date > datetime.date(2026,1,1) + return all(conditions()) + + + def device_forms_completed(self): + return all((a.device_agreement_completed() for a in self.application_set.active())) + + def to_dict(self): sg = self # TODO - this logic is repeated in the API class facilitators = [f.user.first_name for f in sg.facilitator_set.all()] @@ -255,6 +279,7 @@ def to_dict(self): "facilitator_goal": sg.facilitator_goal, "facilitator_concerns": sg.facilitator_concerns, "draft": sg.draft, + "signup_limit": sg.signup_limit or None, "signup_count": sg.application_set.active().count(), "signup_url": reverse('studygroups_signup', args=(slugify(sg.venue_name, allow_unicode=True), sg.id,)), } @@ -334,6 +359,11 @@ def digital_literacy_for_display(self): answers = json.loads(self.signup_questions) return { q: {'question_text': text, 'answer': answers.get(q), 'answer_text': dict(self.DIGITAL_LITERACY_CHOICES).get(answers.get(q)) if q in answers else ''} for q, text in list(self.DIGITAL_LITERACY_QUESTIONS.items()) if answers.get(q) } + def device_agreement_completed(self): + application_data = json.loads(self.signup_questions) + return len(application_data.get('device_agreement', '')) > 3 + + class Meeting(LifeTimeTrackingModel): study_group = models.ForeignKey('studygroups.StudyGroup', on_delete=models.CASCADE) @@ -569,12 +599,16 @@ class Feedback(LifeTimeTrackingModel): (AWFUL, _('Awful')), ] - study_group_meeting = models.ForeignKey('studygroups.Meeting', on_delete=models.CASCADE) # TODO should this be a OneToOneField? + study_group_meeting = models.ForeignKey('studygroups.Meeting', on_delete=models.CASCADE) feedback = models.TextField(blank=True) # Shared with learners. This is being deprecated, but kept for retaining past data. attendance = models.PositiveIntegerField(blank=True, null=True) + granular_attendance = models.JSONField(default=dict) reflection = models.TextField(blank=True) # Shared with team and P2PU rating = models.CharField(choices=RATING, max_length=16, blank=True) + + + def reflection_json(self): if self.reflection: return json.loads(self.reflection) diff --git a/studygroups/tests/test_device_allocation.py b/studygroups/tests/test_device_allocation.py new file mode 100644 index 00000000..08f23ddf --- /dev/null +++ b/studygroups/tests/test_device_allocation.py @@ -0,0 +1,188 @@ +# coding: utf-8 +from django.test import TestCase, override_settings +from django.test import Client +from django.core import mail +from django.contrib.auth.models import User, Group +from django.utils import timezone +from django.utils.translation import get_language +from django.urls import reverse +from django.conf import settings + +from unittest.mock import patch +from freezegun import freeze_time + +from studygroups.models import StudyGroup +from studygroups.models import Team +from studygroups.models import TeamMembership +from studygroups.models.device_allocation import DeviceAllocation +from studygroups.models.device_allocation import check_user_device_allocation +from custom_registration.models import create_user + +import datetime +import urllib.request, urllib.parse, urllib.error +import json + + + +""" +Tests for when facilitators interact with the system +""" +class TestFacilitatorViews(TestCase): + + fixtures = ['test_teams.json', 'test_courses.json', 'test_studygroups.json'] + + def setUp(self): + with patch('custom_registration.signals.send_email_confirm_email'): + user = create_user('bob@example.net', 'bob', 'test', 'password') + # Assign user to team + team = Team.objects.create(name='test team', page_slug='digital-detroit') + TeamMembership.objects.create(team=team, user=user, role=TeamMembership.MEMBER) + self.facilitator = user + + mailchimp_patcher = patch('studygroups.models.profile.update_mailchimp_subscription') + self.mock_maichimp = mailchimp_patcher.start() + self.addCleanup(mailchimp_patcher.stop) + + + def test_create_study_group_with_limit(self): + c = Client() + c.login(username='bob@example.net', password='password') + data = { + "name": "Test learning circle", + "course": 3, + "description": "Lets learn something", + "course_description": "A real great course", + "venue_name": "75 Harrington", + "venue_details": "top floor", + "venue_address": "75 Harrington", + "city": "Cape Town", + "country": "South Africa", + "country_en": "South Africa", + "region": "Western Cape", + "latitude": 3.1, + "longitude": "1.3", + "place_id": "1", + "online": "false", + "language": "en", + "meetings": [ + { "meeting_date": "2026-02-12", "meeting_time": "17:01" }, + { "meeting_date": "2026-02-19", "meeting_time": "17:01" }, + ], + "meeting_time": "17:01", + "duration": 50, + "timezone": "UTC", + "image": "/media/image.png", + "facilitator_concerns": "blah blah", + } + url = '/api/learning-circle/' + self.assertEqual(StudyGroup.objects.all().count(), 4) + + resp = c.post(url, data=json.dumps(data), content_type='application/json') + self.assertEqual(resp.status_code, 200) + + self.assertEqual(resp.json(), { + "status": "error", + 'errors': { + 'signup_limit': [ + 'You need to specify a signup limit for this learning circle less than or equal to 0', + ], + }, + }) + + DeviceAllocation.objects.create(user=self.facilitator, start_date=datetime.date(2026,1,1), cutoff_date=datetime.date(2027,1,1), amount=20) + resp = c.post(url, data=json.dumps(data), content_type='application/json') + self.assertEqual(resp.status_code, 200) + + + self.assertEqual(resp.json(), { + "status": "error", + 'errors': { + 'signup_limit': [ + 'You need to specify a signup limit for this learning circle less than or equal to 20', + ], + }, + }) + + data['signup_limit'] = 12 + resp = c.post(url, data=json.dumps(data), content_type='application/json') + + lc = StudyGroup.objects.all().last() + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.json(), { + "status": "created", + "studygroup_url": "{}://{}/en/studygroup/{}/".format(settings.PROTOCOL, settings.DOMAIN, lc.pk) + }) + + self.assertEqual(StudyGroup.objects.all().count(), 5) + + self.assertEqual(check_user_device_allocation(self.facilitator, datetime.date(2026,2,2)), 8) + + + def test_update_learning_circle(self): + self.facilitator.profile.email_confirmed_at = timezone.now() + self.facilitator.profile.save() + + c = Client() + c.login(username='bob@example.net', password='password') + data = { + "course": 3, + "description": "Lets learn something", + "course_description": "A real great course", + "venue_name": "75 Harrington", + "venue_details": "top floor", + "venue_address": "75 Harrington", + "city": "Cape Town", + "country": "South Africa", + "country_en": "South Africa", + "region": "Western Cape", + "latitude": 3.1, + "longitude": "1.3", + "place_id": "4", + "online": "false", + "language": "en", + "meeting_time": "17:01", + "duration": 50, + "timezone": "UTC", + "image": "/media/image.png", + "draft": False, + "meetings": [ + { "meeting_date": "2026-02-12", "meeting_time": "17:01" }, + { "meeting_date": "2026-02-19", "meeting_time": "17:01" }, + ], + "signup_limit": 20, + } + url = '/api/learning-circle/' + self.assertEqual(StudyGroup.objects.all().count(), 4) + DeviceAllocation.objects.create(user=self.facilitator, start_date=datetime.date(2026,1,1), cutoff_date=datetime.date(2027,1,1), amount=20) + + + resp = c.post(url, data=json.dumps(data), content_type='application/json') + self.assertEqual(resp.status_code, 200) + lc = StudyGroup.objects.all().last() + self.assertEqual(resp.json(), { + "status": "created", + "studygroup_url": "{}://{}/en/studygroup/{}/".format(settings.PROTOCOL, settings.DOMAIN, lc.pk) + }) + self.assertEqual(StudyGroup.objects.all().count(), 5) + self.assertEqual(check_user_device_allocation(self.facilitator, datetime.date(2026,2,2)), 0) + + # Update learning circle + lc = StudyGroup.objects.all().last() + self.assertFalse(lc.draft) + url = '/api/learning-circle/{}/'.format(lc.pk) + data["facilitators"] = [f.user_id for f in lc.facilitator_set.all()] + data["signup_limit"] = 21 + + resp = c.post(url, data=json.dumps(data), content_type='application/json') + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.json()['status'], 'error') + + data["signup_limit"] = 19 + resp = c.post(url, data=json.dumps(data), content_type='application/json') + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.json(), { + "status": "updated", + "studygroup_url": "{}://{}/en/studygroup/{}/".format(settings.PROTOCOL, settings.DOMAIN, lc.pk) + }) + self.assertEqual(check_user_device_allocation(self.facilitator, datetime.date(2026,2,2)), 1) + diff --git a/studygroups/tests/test_learner_views.py b/studygroups/tests/test_learner_views.py index 107eeba3..6831175f 100644 --- a/studygroups/tests/test_learner_views.py +++ b/studygroups/tests/test_learner_views.py @@ -212,6 +212,25 @@ def test_update_application(self): self.assertEqual(Application.objects.active().count(), 2) + def test_signup_limit(self): + sg = StudyGroup.objects.get(pk=1) + sg.signup_limit = 1 # must be a lonely learning circle? + sg.save() + + signup_data = self.APPLICATION_DATA.copy() + c = Client() + resp = c.post('/en/signup/foo-bob-1/', signup_data) + self.assertRedirects(resp, '/en/signup/1/success/') + self.assertEqual(Application.objects.active().count(), 1) + + signup_data = self.APPLICATION_DATA.copy() + signup_data['email'] = 'test2@mail.com' + c = Client() + resp = c.post('/en/signup/foo-bob-1/', signup_data) + self.assertEqual(resp.status_code, 200) + self.assertEqual(Application.objects.active().count(), 1) + + def test_unapply(self): c = Client() data = self.APPLICATION_DATA.copy() diff --git a/studygroups/urls.py b/studygroups/urls.py index 87a9beb9..f7be7803 100644 --- a/studygroups/urls.py +++ b/studygroups/urls.py @@ -47,6 +47,8 @@ from studygroups.views import MessageView from studygroups.views import MeetingRecap from studygroups.views import MeetingRecapDismiss +from studygroups.views import ContentView +from studygroups.views.learner import DeviceAgreementView from . import views @@ -66,6 +68,9 @@ re_path(r'^studygroup/create/legacy/$', StudyGroupCreateLegacy.as_view(), name='studygroups_studygroup_create_legacy'), re_path(r'^studygroup/(?P[\d]+)/$', views.view_study_group, name='studygroups_view_study_group'), re_path(r'^studygroup/(?P[\d]+)/learn/$', views.StudyGroupParticipantView.as_view(), name='studygroups_view_learning_circle_participant'), + + re_path(r'^studygroup/(?P[\d]+)/device_agreement/$', views.DeviceAgreementView.as_view(), name='studygroups_device_agreement'), + re_path(r'^studygroup/(?P[\d]+)/edit/$', StudyGroupUpdate.as_view(), name='studygroups_edit_study_group'), re_path(r'^studygroup/(?P[\d]+)/edit/legacy/$', StudyGroupUpdateLegacy.as_view(), name='studygroups_studygroup_edit_legacy'), re_path(r'^studygroup/(?P[\d]+)/delete/$', StudyGroupDelete.as_view(), name='studygroups_studygroup_delete'), @@ -107,6 +112,8 @@ re_path(r'^course/(?P[\d]+)/edit/$', CourseUpdate.as_view(), name='studygroups_course_edit'), re_path(r'^course/(?P[\d]+)/delete/$', CourseDelete.as_view(), name='studygroups_course_delete'), + re_path(r'^studygroup/(?P[\d]+)/content/(?P[\d]+)/$', ContentView.as_view(), name='studygroups_content_view'), + re_path(r'^facilitator/$', RedirectView.as_view(url='/'), name='studygroups_facilitator_deprecated'), re_path(r'^facilitator/team-invitation/$', InvitationConfirm.as_view(), name='studygroups_facilitator_invitation_confirm'), re_path(r'^facilitator/team-invitation/(?P[\d]+)/$', InvitationConfirm.as_view(), name='studygroups_facilitator_invitation_confirm'), diff --git a/studygroups/views/api.py b/studygroups/views/api.py index 92cc3e29..bba5ab43 100644 --- a/studygroups/views/api.py +++ b/studygroups/views/api.py @@ -46,6 +46,7 @@ from studygroups.models.team import eligible_team_by_email_domain from studygroups.models.team import get_team_users from studygroups.models.learningcircle import generate_meeting_reminder +from studygroups.models.device_allocation import check_user_device_allocation from studygroups.tasks import send_cofacilitator_email from studygroups.tasks import send_cofacilitator_removed_email @@ -139,7 +140,8 @@ def serialize_learning_circle(sg): "report_url": sg.report_url(), "studygroup_path": reverse('studygroups_view_study_group', args=(sg.id,)), "draft": sg.draft, - "signup_count": sg.application_set.active().count(), + "signup_limit": sg.signup_limit or None, + "signup_count": sg.application_set.active().count(), # should this be serialized for public API endpionts? "signup_open": sg.signup_open and sg.end_date > datetime.date.today(), } @@ -721,6 +723,7 @@ def _make_learning_circle_schema(request): "duration": schema.integer(required=True), "timezone": schema.text(required=True, length=128), "signup_question": schema.text(length=256), + "signup_limit": schema.integer(required=False), "facilitators": _facilitators_validator, "facilitator_goal": schema.text(length=256), "facilitator_concerns": schema.text(length=256), @@ -749,6 +752,7 @@ def post(self, request): if not team_membership: errors = { 'facilitators': ['Facilitator not part of a team']} return json_response(request, {"status": "error", "errors": errors}) + team = TeamMembership.objects.active().filter(user=request.user).first().team team_list = team.teammembership_set.active().values_list('user', flat=True) if not all(item in team_list for item in data.get('facilitators', [])): @@ -760,10 +764,23 @@ def post(self, request): start_date = data.get('meetings')[0].get('meeting_date') end_date = data.get('meetings')[-1].get('meeting_date') + + # check if learning circle is part of digital detroit project + if TeamMembership.objects.active().filter(user=request.user).exists() and \ + TeamMembership.objects.active().filter(user=request.user).first().team.page_slug == 'digital-detroit': + # if signup limit exceeds device allocation return error + signup_limit = data.get('signup_limit', None) + available_devices = check_user_device_allocation(request.user, start_date) + if signup_limit is None or signup_limit > available_devices: + errors = { 'signup_limit': [f'You need to specify a signup limit for this learning circle less than or equal to {available_devices}']} + return json_response(request, {"status": "error", "errors": errors}) + + # create learning circle study_group = StudyGroup( name=data.get('name', None), course=data.get('course'), + course_content=data.get('course').course_content, course_description=data.get('course_description', None), created_by=request.user, description=data.get('description'), @@ -788,7 +805,8 @@ def post(self, request): image=data.get('image_url'), signup_question=data.get('signup_question', ''), facilitator_goal=data.get('facilitator_goal', ''), - facilitator_concerns=data.get('facilitator_concerns', '') + facilitator_concerns=data.get('facilitator_concerns', ''), + signup_limit=data.get('signup_limit', None) ) # use course.caption if course_description is not set @@ -847,6 +865,17 @@ def post(self, request, *args, **kwargs): errors = { 'facilitators': ['Facilitators not part of the same team']} return json_response(request, {"status": "error", "errors": errors}) + + # if part of digital detroit project + if study_group.show_device_agreement(): + devices = check_user_device_allocation(request.user, study_group.start_date) + max_limit = study_group.signup_limit + devices + signup_limit = data.get('signup_limit', None) + if signup_limit is None or signup_limit > max_limit: + errors = { 'signup_limit': [f'You need to specify a signup limit for this learning circle less than or equal to {max_limit}']} + return json_response(request, {"status": "error", "errors": errors}) + + # determine if meeting reminders should be regenerated regenerate_reminders = any([ study_group.name != data.get('name'), @@ -888,6 +917,7 @@ def post(self, request, *args, **kwargs): study_group.timezone = data.get('timezone') study_group.image = data.get('image_url') study_group.signup_question = data.get('signup_question', '') + study_group.signup_limit = data.get('signup_limit', None) study_group.facilitator_goal = data.get('facilitator_goal', '') study_group.facilitator_concerns = data.get('facilitator_concerns', '') diff --git a/studygroups/views/drf.py b/studygroups/views/drf.py index 9ec8422d..c278cac6 100644 --- a/studygroups/views/drf.py +++ b/studygroups/views/drf.py @@ -27,7 +27,7 @@ def get_url(self, obj): class Meta: model = Feedback - fields = ['rating', 'attendance', 'reflection', 'study_group_meeting', 'url'] + fields = ['rating', 'attendance', 'granular_attendance', 'reflection', 'study_group_meeting', 'url'] class IsGroupFacilitator(permissions.BasePermission): diff --git a/studygroups/views/facilitate.py b/studygroups/views/facilitate.py index 74923ed6..c8aa1642 100644 --- a/studygroups/views/facilitate.py +++ b/studygroups/views/facilitate.py @@ -61,6 +61,7 @@ from studygroups.views.api import serialize_learning_circle from studygroups.models.team import eligible_team_by_email_domain from studygroups.models import weekly_update_data +from studygroups.models.device_allocation import check_user_device_allocation logger = logging.getLogger(__name__) @@ -91,6 +92,17 @@ def view_study_group(request, study_group_id): context['expand_meeting'] = meeting_number context['meeting_rating'] = rating + if study_group.course_content: + import courses.models + course_uri = courses.models.course_id2uri(study_group.course_content.pk) + sections = courses.models.get_course_content(course_uri) + if len(sections) > 1: + context['content_link'] = reverse( + 'studygroups_content_view', + args=(study_group.pk, sections[1]['id'],) #TODO skipping nr 0 + ) + + return render(request, 'studygroups/view_study_group.html', context) @@ -284,6 +296,10 @@ def get_context_data(self, **kwargs): context['team'] = json.dumps([t.to_dict() for t in team.teammembership_set.active()]) if Course.objects.active().filter(courselist__team=team).exists(): context['team_course_list'] = True + + if team.page_slug == 'digital-detroit': + context['max_signups'] = check_user_device_allocation(self.request.user, datetime.datetime.today()) + return context @@ -331,6 +347,9 @@ def get_context_data(self, **kwargs): if Reminder.objects.filter(study_group=self.object, edited_by_facilitator=True, sent_at__isnull=True).exists(): context['reminders_edited'] = True messages.warning(self.request, _('You have edited meeting reminders for meetings in the future. Update the learning circle description or venue information will cause the reminders to be regenerated and your updates to be lost')) + if self.object.team and self.object.team.page_slug == 'digital-detroit': + context['max_signups'] = self.object.signup_limit + check_user_device_allocation(self.request.user, datetime.datetime.today()) + return context @@ -591,8 +610,9 @@ def add_learner(request, study_group_id): messages.warning(request, _('User with the given email address already signed up.')) elif application.mobile and Application.objects.active().filter(mobile=application.mobile, study_group=study_group).exists(): messages.warning(request, _('User with the given mobile number already signed up.')) + elif study_group.at_capacity: + messages.warning(request, _('The learning circle is full')) else: - # TODO - remove accepted_at or use accepting applications flow application.accepted_at = timezone.now() application.save() return http.HttpResponseRedirect(url) @@ -608,14 +628,20 @@ def add_learner(request, study_group_id): @method_decorator([user_is_group_facilitator, study_group_is_published], name='dispatch') class ApplicationCreateMultiple(FormView): + template_name = 'studygroups/add_learners.html' - form_class = modelformset_factory( - Application, - form=ApplicationInlineForm, - extra=5 - ) success_url = reverse_lazy('studygroups_facilitator') + def dispatch(self, request, *args, **kwargs): + study_group_id = self.kwargs.get('study_group_id') + study_group = get_object_or_404(StudyGroup, pk=study_group_id) + + if study_group.at_capacity: + url = reverse('studygroups_view_study_group', args=(study_group_id,)) + messages.warning(request, 'No more available signups, please increase signup limit first') + return http.HttpResponseRedirect(url) + return super().dispatch(request, *args, **kwargs) + def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) study_group_id = self.kwargs.get('study_group_id') @@ -623,13 +649,39 @@ def get_context_data(self, **kwargs): return context def get_form(self): + study_group_id = self.kwargs.get('study_group_id') + study_group = get_object_or_404(StudyGroup, pk=study_group_id) + + signup_rows = 5 + if study_group.signup_limit > 0: + signup_count = study_group.application_set.active().count() + signup_rows = study_group.signup_limit - signup_count + + form_class = modelformset_factory( + Application, + form=ApplicationInlineForm, + extra=signup_rows + ) + queryset = Application.objects.none() - return self.form_class(queryset=queryset, **self.get_form_kwargs()) + kwargs = self.get_form_kwargs() + return form_class(queryset=queryset, **kwargs) def form_valid(self, form): study_group_id = self.kwargs.get('study_group_id') study_group = get_object_or_404(StudyGroup, pk=study_group_id) applications = form.save(commit=False) + + if study_group.signup_limit > 0: + signup_count = study_group.application_set.active().count() + available_signups = study_group.signup_limit - signup_count + + if len(applications) > available_signups: + url = reverse('studygroups_view_study_group', args=(study_group_id,)) + messages.warning(self.request, 'Adding participants will exceed the signup limit, please increase the limit first.') + return http.HttpResponseRedirect(url) + + for application in applications: if application.email and Application.objects.active().filter(email__iexact=application.email, study_group=study_group).exists(): messages.warning(self.request, _(f'A learner with the email address {application.email} has already signed up.')) diff --git a/studygroups/views/learner.py b/studygroups/views/learner.py index c30781b5..fb37de19 100644 --- a/studygroups/views/learner.py +++ b/studygroups/views/learner.py @@ -6,6 +6,7 @@ from studygroups.utils import render_to_string_ctx from django.urls import reverse, reverse_lazy from django.core.mail import EmailMultiAlternatives +from django.core.serializers.json import DjangoJSONEncoder from django.contrib import messages from django.conf import settings from django import http @@ -36,6 +37,7 @@ from studygroups.forms import ApplicationForm from studygroups.forms import OptOutForm from studygroups.forms import OptOutConfirmationForm +from studygroups.forms import DeviceAgreementForm from studygroups.utils import check_rsvp_signature from studygroups.utils import check_unsubscribe_signature from studygroups.views.api import serialize_learning_circle @@ -48,6 +50,7 @@ import json import urllib import logging +import re logger = logging.getLogger(__name__) @@ -57,7 +60,12 @@ def signup(request, location, study_group_id): if not study_group.deleted_at is None: raise http.Http404(_("Learning circle does not exist")) + at_capacity = study_group.at_capacity + if request.method == 'POST': + # handle over capacity signup + if at_capacity: + messages.warning(request, 'Unfortunately all the available spots for this learning circle has already been taken.') recaptcha_response = request.POST.get('g-recaptcha-response') data = { 'secret': settings.RECAPTCHA_SECRET_KEY, @@ -66,7 +74,7 @@ def signup(request, location, study_group_id): r = requests.post('https://www.google.com/recaptcha/api/siteverify', data=data) captcha_result = r.json() form = ApplicationForm(request.POST, initial={'study_group': study_group}) - if form.is_valid() and study_group.signup_open == True and study_group.draft == False and captcha_result.get('success'): + if form.is_valid() and study_group.signup_open == True and study_group.draft == False and captcha_result.get('success') and not at_capacity: application = form.save(commit=False) if application.email and Application.objects.active().filter(email__iexact=application.email, study_group=study_group).exists(): old_application = Application.objects.active().filter(email__iexact=application.email, study_group=study_group).first() @@ -93,6 +101,7 @@ def signup(request, location, study_group_id): context = { 'form': form, 'study_group': study_group, + 'at_capacity': at_capacity, 'meetings': meetings, 'mapbox_token': settings.MAPBOX_TOKEN, 'completed': last_meeting is not None and last_meeting.meeting_date < datetime.date.today(), @@ -140,6 +149,71 @@ def conditions(): return http.HttpResponseRedirect(url) +@method_decorator(login_required, name="dispatch") +class DeviceAgreementView(FormView): + template_name = 'studygroups/dd_device_agreement.html' + form_class = DeviceAgreementForm + + def dispatch(self, request, *args, **kwargs): + study_group_id = self.kwargs.get('study_group_id') + study_group = get_object_or_404(StudyGroup, pk=study_group_id) + + # ensure viewer is signed up and learning circle meets criteria + if not study_group.application_set.active().filter(email=self.request.user.email).exists() or not study_group.show_device_agreement(): + url = reverse('studygroups_facilitator') + return http.HttpResponseRedirect(url) + + return super().dispatch(request, *args, **kwargs) + + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + study_group_id = self.kwargs.get('study_group_id') + study_group = get_object_or_404(StudyGroup, pk=study_group_id) + context['study_group'] = study_group + return context + + + def get_initial(self): + study_group_id = self.kwargs.get('study_group_id') + study_group = get_object_or_404(StudyGroup, pk=study_group_id) + application = study_group.application_set.active().filter(email=self.request.user.email).first() + initial = { + "email_address": application.email, + "first_name": application.name, + } + + if application.mobile: + initial["phone_number"] = application.mobile + + application_data = json.loads(application.signup_questions) + if 'device_agreement' in application_data: + initial.update(application_data.get('device_agreement')) + + return initial + + + def form_valid(self, form): + study_group_id = self.kwargs.get('study_group_id') + study_group = get_object_or_404(StudyGroup, pk=study_group_id) + + # save data in Application.signup_questions as json field + application = study_group.application_set.active().filter( + email=self.request.user.email + ).first() + application_data = json.loads(application.signup_questions) + form.cleaned_data['phone_number'] = str(form.cleaned_data['phone_number']) + application_data['device_agreement'] = form.cleaned_data + application.signup_questions = json.dumps(application_data, cls=DjangoJSONEncoder) + application.save() + + redirect_url = reverse( + 'studygroups_view_learning_circle_participant', + args=(study_group.id,) + ) + return http.HttpResponseRedirect(redirect_url) + + class OptOutView(FormView): template_name = 'studygroups/optout.html' form_class = OptOutForm @@ -287,6 +361,18 @@ def get(self, request, *args, **kwargs): context = self.get_context_data(**kwargs) context['study_group'] = study_group context['application'] = application + context['content_link'] = None + if study_group.course_content: + import courses.models + course_uri = courses.models.course_id2uri(study_group.course_content.pk) + sections = courses.models.get_course_content(course_uri) + if len(sections) > 1: + context['content_link'] = reverse( + 'studygroups_content_view', + args=(study_group.pk, sections[1]['id'],) #TODO skipping nr 0 + ) + + meetings = study_group.meeting_set.active().order_by('meeting_date', 'meeting_time') messages = study_group.reminder_set.filter(sent_at__isnull=False) @@ -352,6 +438,13 @@ def _message_to_dict(message): 'email': application.email, } } + + if study_group.show_device_agreement(): + react_data['device_agreement_url'] = reverse('studygroups_device_agreement', args=(study_group.pk,)) + application_data = json.loads(application.signup_questions) + if application_data.get('device_agreement'): + react_data['device_agreement_completed'] = True + context['react_data'] = react_data return render(request, self.template_name, context) @@ -409,3 +502,49 @@ def get(self, request, *args, **kwargs): } return render(request, self.template_name, context) + + +@method_decorator(login_required, name="dispatch") +class ContentView(TemplateView): + template_name = 'studygroups/content_view.html' + + def get(self, request, *args, **kwargs): + study_group = get_object_or_404(StudyGroup, pk=kwargs.get('study_group_id')) + if study_group.course_content is None: + raise http.Http404(_("Content for this learning circle not found.")) + + # check user permissions + application = Application.objects.active().filter( + email__iexact=self.request.user.email, + study_group=study_group + ).first() + if not ( application or self.request.user in study_group.facilitator_set.all() or self.request.user.is_staff): + redirect_url = reverse( + 'studygroups_signup', + args=(slugify(study_group.venue_name, allow_unicode=True), study_group.id) + ) + return HttpResponseRedirect(redirect_url) + + import content.db + import courses.models + content = content.db.Content.objects.get(pk=kwargs.get('pk')) + course_uri = courses.models.course_id2uri(study_group.course_content.pk) + sections = courses.models.get_course_content(course_uri)[1:] #TODO + + # TODO check if content is part of study_group.course_content + if content.id not in [s['id'] for s in sections]: + return HttpResponseRedirect('/') + + markdown_content = content.latest.content + h1_headings = re.findall(r'^\s*#(?!#)\s+(.+)$', markdown_content, flags=re.MULTILINE) + + context = { + "scroll_top": True, + "study_group": study_group, + "content": markdown_content, + "content_title": content.latest.title, + "sections": sections, + "h1_headings": h1_headings, + } + + return render(request, self.template_name, context) diff --git a/templates/base.html b/templates/base.html index 74cfb69d..d3fc7146 100644 --- a/templates/base.html +++ b/templates/base.html @@ -61,7 +61,7 @@ {% include 'nav.html' %} -
    +
    {% for message in messages %} {% endif %} + {% elif at_capacity %} +

    {% trans "Unfortunately there are no more available spots for this learning circle." %}

    {% else %}

    {% trans "Unfortunately this learning circle is now closed for signup." %}

    {% endif %} diff --git a/templates/studygroups/studygroup_form.html b/templates/studygroups/studygroup_form.html index 79fc82b3..1b630a21 100644 --- a/templates/studygroups/studygroup_form.html +++ b/templates/studygroups/studygroup_form.html @@ -29,6 +29,7 @@ window.team = team; window.currentUserId = {% if user.id %}{{user.id|safe}}{% else %}undefined{% endif %}; window.teamCourseList = {% if team_course_list %}true{% else %}false{% endif %}; + {% if max_signups %}window.maxSignups = {{max_signups}};{% endif %} {% if object.pk %} {% render_bundle 'learning-circle-manage' %}