From 9fae21ba2d7fce1ab93dbe79213687279c58b32d Mon Sep 17 00:00:00 2001 From: Dirk Uys Date: Fri, 25 Jul 2025 17:17:49 +0200 Subject: [PATCH 01/63] Add content elements from lernanta --- content/__init__.py | 0 content/db.py | 17 + content/forms.py | 9 + content/migrations/0001_initial.py | 39 + content/migrations/__init__.py | 0 content/models.py | 106 +++ content/templatetags/__init__.py | 0 content/templatetags/content_tags.py | 27 + content/tests.py | 59 ++ content/utils.py | 16 + courses/__init__.py | 0 courses/admin.py | 6 + courses/badges_oembed.py | 39 + courses/db.py | 77 ++ courses/decorators.py | 20 + courses/export.py | 15 + courses/forms.py | 65 ++ courses/models.py | 632 ++++++++++++++++ courses/templates/courses/content.html | 65 ++ .../courses/content_form_buttons_snip.html | 15 + .../templates/courses/content_form_snip.html | 51 ++ courses/templates/courses/course.html | 24 + .../courses/course_admin_content.html | 37 + .../courses/course_announcement.html | 21 + courses/templates/courses/course_badges.html | 58 ++ courses/templates/courses/course_base.html | 93 +++ .../courses/course_comment_snip.html | 41 + .../courses/course_delete_confirmation.html | 22 + .../templates/courses/course_discussion.html | 40 + courses/templates/courses/course_people.html | 40 + .../templates/courses/course_settings.html | 196 +++++ courses/templates/courses/course_sidebar.html | 84 +++ .../templates/courses/course_user_snip.html | 24 + courses/templates/courses/create_content.html | 72 ++ courses/templates/courses/create_course.html | 72 ++ courses/templates/courses/edit_content.html | 68 ++ .../courses/emails/course_announcement.txt | 5 + .../emails/course_announcement_subject.txt | 3 + .../templates/courses/emails/course_join.txt | 10 + .../courses/emails/course_join_subject.txt | 3 + .../courses/preview_content_snip.html | 3 + courses/tests.py | 200 +++++ courses/urls.py | 128 ++++ courses/utils.py | 35 + courses/views.py | 706 ++++++++++++++++++ learnwithpeople/settings.py | 3 + learnwithpeople/urls.py | 1 + media/__init__.py | 0 media/db.py | 7 + media/migrations/0001_initial.py | 22 + media/migrations/__init__.py | 0 media/models.py | 23 + 52 files changed, 3299 insertions(+) create mode 100755 content/__init__.py create mode 100755 content/db.py create mode 100755 content/forms.py create mode 100644 content/migrations/0001_initial.py create mode 100644 content/migrations/__init__.py create mode 100755 content/models.py create mode 100755 content/templatetags/__init__.py create mode 100755 content/templatetags/content_tags.py create mode 100755 content/tests.py create mode 100755 content/utils.py create mode 100755 courses/__init__.py create mode 100644 courses/admin.py create mode 100755 courses/badges_oembed.py create mode 100755 courses/db.py create mode 100755 courses/decorators.py create mode 100755 courses/export.py create mode 100755 courses/forms.py create mode 100755 courses/models.py create mode 100755 courses/templates/courses/content.html create mode 100755 courses/templates/courses/content_form_buttons_snip.html create mode 100755 courses/templates/courses/content_form_snip.html create mode 100755 courses/templates/courses/course.html create mode 100755 courses/templates/courses/course_admin_content.html create mode 100755 courses/templates/courses/course_announcement.html create mode 100755 courses/templates/courses/course_badges.html create mode 100755 courses/templates/courses/course_base.html create mode 100755 courses/templates/courses/course_comment_snip.html create mode 100755 courses/templates/courses/course_delete_confirmation.html create mode 100755 courses/templates/courses/course_discussion.html create mode 100755 courses/templates/courses/course_people.html create mode 100755 courses/templates/courses/course_settings.html create mode 100755 courses/templates/courses/course_sidebar.html create mode 100755 courses/templates/courses/course_user_snip.html create mode 100755 courses/templates/courses/create_content.html create mode 100755 courses/templates/courses/create_course.html create mode 100755 courses/templates/courses/edit_content.html create mode 100755 courses/templates/courses/emails/course_announcement.txt create mode 100755 courses/templates/courses/emails/course_announcement_subject.txt create mode 100755 courses/templates/courses/emails/course_join.txt create mode 100755 courses/templates/courses/emails/course_join_subject.txt create mode 100755 courses/templates/courses/preview_content_snip.html create mode 100755 courses/tests.py create mode 100755 courses/urls.py create mode 100755 courses/utils.py create mode 100755 courses/views.py create mode 100755 media/__init__.py create mode 100755 media/db.py create mode 100644 media/migrations/0001_initial.py create mode 100644 media/migrations/__init__.py create mode 100755 media/models.py diff --git a/content/__init__.py b/content/__init__.py new file mode 100755 index 000000000..e69de29bb diff --git a/content/db.py b/content/db.py new file mode 100755 index 000000000..4bb9e9ea6 --- /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 100755 index 000000000..fab0a1067 --- /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 000000000..3384df507 --- /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 000000000..e69de29bb diff --git a/content/models.py b/content/models.py new file mode 100755 index 000000000..d07316291 --- /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 100755 index 000000000..e69de29bb diff --git a/content/templatetags/content_tags.py b/content/templatetags/content_tags.py new file mode 100755 index 000000000..fac61cf78 --- /dev/null +++ b/content/templatetags/content_tags.py @@ -0,0 +1,27 @@ +from django import template +from django.template.defaultfilters import stringfilter + +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 +@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 html + +convert_content.is_safe = True diff --git a/content/tests.py b/content/tests.py new file mode 100755 index 000000000..a026597e1 --- /dev/null +++ b/content/tests.py @@ -0,0 +1,59 @@ +from django.test import Client +from django.contrib.auth.models import User +from users.models import create_profile + +from test_utils import TestCase + +from content import models as content_model +from 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' + + def setUp(self): + self.client = Client() + self.locale = 'en' + + django_user = User( + username=self.test_username, + email=self.test_email, + ) + self.user = create_profile(django_user) + self.user.set_password(self.test_password) + self.user.save() + + django_user2 = User( + username=self.test_username2, + email=self.test_email2, + ) + self.user2 = create_profile(django_user2) + self.user2.set_password(self.test_password2) + self.user2.save() + + + 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' + ) + + + 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 100755 index 000000000..3dbdb0d73 --- /dev/null +++ b/content/utils.py @@ -0,0 +1,16 @@ +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 + return content + +def clean_trusted_user_content(content): + bleach.clean(content, strip=True) + #TODO + return content diff --git a/courses/__init__.py b/courses/__init__.py new file mode 100755 index 000000000..e69de29bb diff --git a/courses/admin.py b/courses/admin.py new file mode 100644 index 000000000..15b14b45a --- /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/badges_oembed.py b/courses/badges_oembed.py new file mode 100755 index 000000000..805c9eb9a --- /dev/null +++ b/courses/badges_oembed.py @@ -0,0 +1,39 @@ +import requests +from django.conf import settings + +from content import models as content_model +from courses import models as course_model + + +class BadgeNotFoundException(Exception): + pass + + +def request_oembedded_content(url): + """ Retrieves oembed json from API endpoint""" + endpoint_url = settings.BADGES_OEMBED_URL + + try: + r = requests.get(endpoint_url, params={'url': url}) + except (requests.exceptions.RequestException, + requests.exceptions.ConnectionError, + requests.exceptions.HTTPError) as e: + return e + return r + + +def add_content_from_response(course_uri, url, user_uri): + content = None + response = request_oembedded_content(url) + if response.status_code == 200: + content = response.json + content_data = { + 'title': content['title'], + 'content': content['html'], + 'author_uri': user_uri, + } + content = content_model.create_content(**content_data) + course_model.add_course_content(course_uri, content['uri']) + else: + raise BadgeNotFoundException + return content diff --git a/courses/db.py b/courses/db.py new file mode 100755 index 000000000..ae923129c --- /dev/null +++ b/courses/db.py @@ -0,0 +1,77 @@ +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) + + +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) + + +class Cohort(models.Model): + + OPEN = "OPEN" + MODERATED = "MODERATED" + CLOSED = "CLOSED" + SIGNUP_MODELS = ( + (OPEN, "Anyone can sign up"), + (MODERATED, "Signups are moderated"), + (CLOSED, "Signups are closed"), + ) + + ROLLING = "ROLLING" + FIXED = "FIXED" + TERM_CHOICES = ( + (ROLLING, "Rolling"), + (FIXED, "Fixed"), + ) + + course = models.ForeignKey(Course, models.CASCADE, related_name="cohort_set") + term = models.CharField(max_length=32, choices=TERM_CHOICES) + start_date = models.DateTimeField(blank=True, null=True) + end_date = models.DateTimeField(blank=True, null=True) + signup = models.CharField(max_length=32, choices=SIGNUP_MODELS) + + +class CohortSignup(models.Model): + + LEARNER = "LEARNER" + ORGANIZER = "ORGANIZER" + SIGNUP_ROLE_CHOICES = ( + (LEARNER, "Learner"), + (ORGANIZER, "Organizer"), + ) + + cohort = models.ForeignKey(Cohort, models.CASCADE, related_name="signup_set") + user_uri = models.CharField(max_length=256) + role = models.CharField(max_length=10, choices=SIGNUP_ROLE_CHOICES) + signup_date = models.DateTimeField(auto_now_add=True) + leave_date = models.DateTimeField(blank=True, null=True) + + +class CohortComment(models.Model): + + cohort = models.ForeignKey(Cohort, models.CASCADE, related_name="comment_set") + comment_uri = models.CharField(max_length=256) + reference_uri = models.CharField(max_length=256) + diff --git a/courses/decorators.py b/courses/decorators.py new file mode 100755 index 000000000..a050fdce6 --- /dev/null +++ b/courses/decorators.py @@ -0,0 +1,20 @@ +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) + cohort = course_model.get_course_cohort(course_uri) + organizer = course_model.is_cohort_organizer( + user_uri, cohort['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/export.py b/courses/export.py new file mode 100755 index 000000000..3454e352f --- /dev/null +++ b/courses/export.py @@ -0,0 +1,15 @@ +from apps.courses import db +import unicodecsv + +def export_signup_csv(filename): + with open(filename, 'w') as fout: + writer = unicodecsv.writer(fout) + writer.writerow(['signup_date', 'leave_date', 'role', 'user', 'course']) + for signup in db.CohortSignup.objects.all(): + writer.writerow([ + signup.signup_date.isoformat(), + signup.leave_date.isoformat() if signup.leave_date else '', + signup.role, + signup.user_uri, + signup.cohort.course.id + ]) diff --git a/courses/forms.py b/courses/forms.py new file mode 100755 index 000000000..7a8919122 --- /dev/null +++ b/courses/forms.py @@ -0,0 +1,65 @@ +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 CourseTermForm(forms.Form): + start_date = forms.DateField() + end_date = forms.DateField() + + +class CourseTagsForm(forms.Form): + tags = forms.CharField(max_length=256) + + +class CourseEmbeddedUrlForm(forms.Form): + url = forms.URLField(max_length=300) + + +class CourseStatusForm(forms.Form): + STATUS_CHOICES = [ + ('draft', _('Draft'), ), + ('published', _('Published'), ), + ('archived', _('Archived'), ), + ] + + status = forms.ChoiceField(choices=STATUS_CHOICES, widget=RadioSelect) + + +SIGNUP_CHOICES = [ + ("OPEN", _("Open"),), + ("MODERATED", _("Moderated"),), + ("CLOSED", _("Closed"),) +] + +class CohortSignupForm(forms.Form): + signup = forms.ChoiceField(choices=SIGNUP_CHOICES, widget=RadioSelect) diff --git a/courses/models.py b/courses/models.py new file mode 100755 index 000000000..5936aff4d --- /dev/null +++ b/courses/models.py @@ -0,0 +1,632 @@ +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 get_user_courses(user_uri): + """ return courses organized or participated in by an user """ + signups = db.CohortSignup.objects.filter(user_uri=user_uri, leave_date__isnull=True, cohort__course__archived=False) + courses = [] + for signup in signups: + try: + course = get_course(course_id2uri(signup.cohort.course.id)) + except ResourceDeletedException: + continue + course_data = { + "id": course['id'], + "title": course['title'], + "user_role": signup.role, + "url": reverse("courses_show", kwargs={"course_id": course["id"], "slug": course["slug"]}), + } + if "image_uri" in course: + course_data["image_url"] = media_model.get_image(course['image_uri'])['url'] + courses += [course_data] + return courses + + +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']) + create_course_cohort("/uri/course/{0}".format(course_db.id), organizer_uri) + course = get_course("/uri/course/{0}".format(course_db.id)) + + #learn_api_data = get_course_learn_api_data(course['uri']) + #learn_model.add_course_listing(**learn_api_data) + #learn_model.add_course_to_list(learn_api_data['course_url'], "drafts") + + # TODO notify admins + 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() + #update_course_learn_api(course_uri) + + return get_course(course_uri) + + +# def update_course_learn_api(course_uri): +# """ send updated course info to the learn API to update course listing """ +# course_db = _get_course_db(course_uri) +# try: +# learn_model.update_course_listing(**get_course_learn_api_data(course_uri)) +# except: +# log.error("Could not update learn index information!") + + +def get_course_learn_api_data(course_uri): + """ return data used by the learn API to list courses """ + course = get_course(course_uri) + + course_url = reverse( + 'courses_slug_redirect', + kwargs={'course_id': course["id"]} + ) + data_url = reverse('courses_learn_api_data', kwargs={'course_id': course["id"]}) + + learn_api_data = { + "course_url": course_url, + "title": course['title'], + "description": course['description'], + "data_url": data_url, + "language": course['language'], + "thumbnail_url": "", + "tags": get_course_tags(course_uri) + } + + if 'image_uri' in course: + learn_api_data["thumbnail_url"] = media_model.get_image(course['image_uri'])['url'] + + return learn_api_data + + +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"]} + ) + #try: + # learn_model.remove_course_from_list(course_url, "drafts") + #except: + # try: + # learn_model.remove_course_from_list(course_url, "archived") + # except: + # pass + # + #learn_model.add_course_to_list(course_url, "listed") + + # TODO: Notify interested people in new course + 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"]} + ) + + #try: + # learn_model.remove_course_from_list(course_url, "listed") + #except: + # try: + # learn_model.remove_course_from_list(course_url, "drafts") + # except: + # log.error('Course not in any lists') + # + #learn_model.add_course_to_list(course_url, "archived") + 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"]} + ) + + #try: + # learn_model.remove_course_from_list(course_url, 'listed') + #except: + # try: + # learn_model.remove_course_from_list(course_url, 'archived') + # except: + # log.error('Course not in any lists') + # + #learn_model.add_course_to_list(course_url, "drafts") + + 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() + + # remove the course listing + #course_url = reverse( + # 'courses_slug_redirect', + # kwargs={'course_id': course_db.id} + #) + #try: + # lists = learn_model.get_lists_for_course(course_url) + # for course_list in lists: + # learn_model.remove_course_from_list(course_url, course_list['name']) + # learn_model.remove_course_listing(course_url) + #except: + # pass + + +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() + #update_course_learn_api(course_uri) + + +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() + #update_course_learn_api(course_uri) + + +def create_course_cohort(course_uri, organizer_uri): + course_db = _get_course_db(course_uri) + + if course_db.cohort_set.count() != 0: + return None + + cohort_db = db.Cohort( + course=course_db, + term=db.Cohort.ROLLING, + signup=db.Cohort.CLOSED + ) + cohort_db.save() + try: + cohort = get_course_cohort(course_uri) + except: + log.error("Could not create new cohort!") + #TODO raise appropriate exception + raise + add_user_to_cohort(cohort["uri"], organizer_uri, db.CohortSignup.ORGANIZER) + return get_course_cohort(course_uri) + + +def get_course_cohort_uri(course_uri): + course_db = _get_course_db(course_uri) + try: + cohort_db = db.Cohort.objects.get(course=course_db) + except: + raise DataIntegrityException + return "/uri/cohort/{0}".format(cohort_db.id) + + +def get_course_cohort(course_uri): + return get_cohort(get_course_cohort_uri(course_uri)) + + +def _get_cohort_db(cohort_uri): + cohort_id = cohort_uri.strip('/').split('/')[-1] + try: + cohort_db = db.Cohort.objects.get(id=cohort_id) + except: + raise ResourceNotFoundException + return cohort_db + + +def get_cohort(cohort_uri): + cohort_db = _get_cohort_db(cohort_uri) + + cohort_data = { + "uri": "/uri/cohort/{0}".format(cohort_db.id), + "course_uri": "/uri/course/{0}".format(cohort_db.course_id), + "term": cohort_db.term, + "signup": cohort_db.signup, + } + #NOTE: fetching course + cohort twice when using get_course_cohort + #NOTE-Q: does cohort.course.id fetch the course data? + #NOTE-A: Yes, cohort.course_id doesn't + + if cohort_db.term != db.Cohort.ROLLING: + cohort_data["start_date"] = cohort_db.start_date.date() + cohort_data["end_date"] = cohort_db.end_date.date() + + cohort_data["users"] = {} + cohort_data["organizers"] = [] + for signup in cohort_db.signup_set.filter(leave_date__isnull=True): + username = signup.user_uri.strip('/').split('/')[-1] + cohort_data["users"][username] = { + "username": username, + "uri": signup.user_uri, + "role": signup.role, + "signup_date": signup.signup_date + } + key = "{0}s".format(signup.role.lower()) + if not key in cohort_data: + cohort_data[key] = [] + cohort_data[key] += [username] + + return cohort_data + + +def get_cohort_size( uri ): + cohort_db = _get_cohort_db(uri) + return cohort_db.signup_set.filter(leave_date__isnull=True).count() + + +def update_cohort( uri, term=None, signup=None, start_date=None, end_date=None ): + cohort_db = _get_cohort_db(uri) + + if term: + cohort_db.term = term + if signup: + cohort_db.signup = signup + if start_date: + cohort_db.start_date = start_date + if end_date: + cohort_db.end_date = end_date + cohort_db.save() + return get_cohort(uri) + + +def user_in_cohort(user_uri, cohort_uri): + cohort_db = _get_cohort_db(cohort_uri) + return cohort_db.signup_set.filter( + user_uri=user_uri, leave_date__isnull=True).exists() + + +def is_cohort_organizer(user_uri, cohort_uri): + cohort_db = _get_cohort_db(cohort_uri) + return cohort_db.signup_set.filter( + user_uri=user_uri, leave_date__isnull=True, + role=db.CohortSignup.ORGANIZER).exists() + + +def add_user_to_cohort(cohort_uri, user_uri, role, notify_organizers=False): + cohort_db = _get_cohort_db(cohort_uri) + + username = user_uri.strip('/').split('/')[-1] + if not User.objects.filter(username=username).exists(): + raise ResourceNotFoundException(u'User {0} does not exist'.format(user_uri)) + + if db.CohortSignup.objects.filter(cohort=cohort_db, user_uri=user_uri, leave_date__isnull=True).exists(): + return None + + signup_db = db.CohortSignup( + cohort=cohort_db, + user_uri=user_uri, + role=role + ) + signup_db.save() + + if notify_organizers: + cohort = get_cohort(cohort_uri) + course = get_course(cohort['course_uri']) + organizers = User.objects.filter(username__in=cohort['organizers']) + context = { + 'course': course, + 'new_user': username, + 'domain': settings.domain, + } + subject_template = 'courses/emails/course_join_subject.txt' + body_template = 'courses/emails/course_join.txt' + #notification_model.send_notifications_i18n( + # organizers, subject_template, body_template, context, + # notification_category='course-signup.course-{0}'.format(course['id']) + #) + + signup = { + "cohort_uri": cohort_uri, + "user_uri": user_uri, + "role": role + } + return signup + + +def remove_user_from_cohort(cohort_uri, user_uri): + cohort_db = _get_cohort_db(cohort_uri) + try: + user_signup_db = cohort_db.signup_set.get( + user_uri=user_uri, leave_date__isnull=True + ) + except: + return False, _("No such user in cohort") + + organizer_count = cohort_db.signup_set.filter( + role=db.CohortSignup.ORGANIZER, + leave_date__isnull=True + ).count() == 1 + + if user_signup_db.role == db.CohortSignup.ORGANIZER and organizer_count == 1: + return False, _("Cannot remove last organizer") + + user_signup_db.leave_date = datetime.datetime.utcnow() + user_signup_db.save() + return True, None + + +def send_course_announcement(course_uri, announcement_text): + course = get_course(course_uri) + cohort = get_course_cohort(course_uri) + users = User.objects.filter(username__in=cohort['users'].keys()) + #notification_model.send_notifications_i18n( + # users, + # 'courses/emails/course_announcement_subject.txt', + # 'courses/emails/course_announcement.txt', + # { + # 'course': course, + # 'announcement_text': announcement_text, + # 'domain': settings.domain + # }, + # notification_category='course-announcement.course-{0}'.format(course['id']) + #) diff --git a/courses/templates/courses/content.html b/courses/templates/courses/content.html new file mode 100755 index 000000000..c860acd86 --- /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 100755 index 000000000..855a26b0b --- /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 100755 index 000000000..d13c413e8 --- /dev/null +++ b/courses/templates/courses/content_form_snip.html @@ -0,0 +1,51 @@ +{% load i18n %} + + +
    + {% 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/course.html b/courses/templates/courses/course.html new file mode 100755 index 000000000..045b3c2e2 --- /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 100755 index 000000000..d475c7b23 --- /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_announcement.html b/courses/templates/courses/course_announcement.html new file mode 100755 index 000000000..015767f82 --- /dev/null +++ b/courses/templates/courses/course_announcement.html @@ -0,0 +1,21 @@ +{% extends "courses/course_base.html" %} + +{% load i18n %} +{% load learn_tags %} +{% load embed %} + +{% block tab-pane-content %} + + +

    {% blocktrans %}Users can opt-out of course announcements and not all users will receive this message. Consider adding the relevant information to the course as content or on the about page{% endblocktrans %}

    +
    +

    +

    + {% csrf_token %} +

    {% trans 'Subject: ' %}{% blocktrans with hashtag=course.hashtag %}P2PU Course Announcement #{{ hashtag }}{% endblocktrans %}

    + +

    + +
    +

    +{% endblock %} diff --git a/courses/templates/courses/course_badges.html b/courses/templates/courses/course_badges.html new file mode 100755 index 000000000..a1df7c7d5 --- /dev/null +++ b/courses/templates/courses/course_badges.html @@ -0,0 +1,58 @@ +{% extends "courses/course_base.html" %} + +{% load i18n %} +{% load learn_tags %} +{% load embed %} +{% load settings_tags %} + +{% block tab-pane-content %} +

    {% trans 'How to Add a Badge to Your P2PU Course' %}

    +
    +
    +

    {% trans 'Create a Badge' %}

    +

    {% blocktrans %} + Head on over to + + badges.p2pu.org + to create your snappy Badge. + {% endblocktrans %} +

    + + {% trans 'Create a Badge' %} + +
    +
    +

    {% trans 'Insert the URL' %}

    +

    {% blocktrans %} + Grab the unique URL for your Badge from + + badges.p2pu.org + and insert it here.' + {% endblocktrans %} +

    +
    + {% csrf_token %} +

    + + {{form.url.errors}} + + +

    +
    +
    +
    +

    {% trans 'Manage Your Badge' %}

    +

    {% blocktrans %} + Navigate over to "Content" and select + "Organize and add content". You can move Badges + up or down in the order you'd like + {% endblocktrans %} +

    +
    + {% trans 'How to manage your Badge' %} +
    +
    +
    + + +{% endblock %} diff --git a/courses/templates/courses/course_base.html b/courses/templates/courses/course_base.html new file mode 100755 index 000000000..5cf7b2512 --- /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_comment_snip.html b/courses/templates/courses/course_comment_snip.html new file mode 100755 index 000000000..52482d24e --- /dev/null +++ b/courses/templates/courses/course_comment_snip.html @@ -0,0 +1,41 @@ + + diff --git a/courses/templates/courses/course_delete_confirmation.html b/courses/templates/courses/course_delete_confirmation.html new file mode 100755 index 000000000..6c1c6b34d --- /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_discussion.html b/courses/templates/courses/course_discussion.html new file mode 100755 index 000000000..c2d7609e3 --- /dev/null +++ b/courses/templates/courses/course_discussion.html @@ -0,0 +1,40 @@ +{% extends "courses/course_base.html" %} + +{% load i18n %} +{% load learn_tags %} +{% load embed %} +{% load settings_tags %} + +{% block tab-pane-content %} +

    {% blocktrans %}This page is for general discussion of the course. Feel free to post questions, comments, ideas.{% endblocktrans %}

    +
    +
    + + + comments powered by Disqus + +{% endblock %} diff --git a/courses/templates/courses/course_people.html b/courses/templates/courses/course_people.html new file mode 100755 index 000000000..86cc4fd57 --- /dev/null +++ b/courses/templates/courses/course_people.html @@ -0,0 +1,40 @@ +{% extends "courses/course_base.html" %} + +{% load i18n %} +{% load learn_tags %} +{% load embed %} + +{% block tab-pane-content %} +
    +

    {% trans "Learners" %}

    + +
    +
    +

    {% trans "Organizers" %}

    + +
    + {% if can_edit %} +

    +

    + {% csrf_token %} +

    + +
    +

    + {% if trusted_user %} + {% trans 'Export emails' %} + {% endif %} + {% endif %} +{% endblock %} diff --git a/courses/templates/courses/course_settings.html b/courses/templates/courses/course_settings.html new file mode 100755 index 000000000..32a8bc187 --- /dev/null +++ b/courses/templates/courses/course_settings.html @@ -0,0 +1,196 @@ +{% 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 }} +

    +
    +
    +
    +
    + {% if cohort.term == 'ROLLING' %} +

    {%trans "Users can join this course at any time."%}

    + {% else %} + {% blocktrans with start_date=cohort.start_date end_date=cohort.end_date %} +

    This course runs from {{ start_date }} to {{end_date}}.

    + {% endblocktrans %} + {% endif %} + +
    +

    {%trans "Set dates"%}

    +
    + {% csrf_token %} +

    {{term_form.start_date}}

    +

    {{term_form.end_date}}

    +

    +
    +
    + {% if cohort.term == 'FIXED' %} +

    {%trans "or" %}

    +
    +
    + {% csrf_token %} +

    +
    +
    + {% endif %} +
    +
    +
    +
    + {% if cohort.signup == 'OPEN' %} +

    {% trans 'Signup is open to anyone.' %}

    + {% endif %} + {% if cohort.signup == 'MODERATED' %} +

    {% trans 'Signup is moderated. To sign up for this course you should contact one of the course organizer.' %}

    + {% endif %} + {% if cohort.signup == 'CLOSED' %} +

    {% trans 'Signup is closed. Only organizers can add more people.' %}

    + {% endif %} + +
    +
    + {% csrf_token %} + {{ signup_form.signup }} + +
    +
    +
    +
    +
    +
    + {% csrf_token %} + + {{ tags_form.tags }} +

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

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

    {% trans 'Meta data' %}

    +

    {% blocktrans %}For more information about the supported meta data, see lrmi.net{% endblocktrans %}

    +
    + {% csrf_token %} +
    +

    + + {{ metadata_form.about }} +

    +

    + + {{ metadata_form.timerequired }} +

    +

    + + {{ metadata_form.typicalagerange }} +

    +

    + + {{ metadata_form.isbasedonurl }} +

    +

    + + {{ metadata_form.educationalrole }} +

    +

    + + {{ metadata_form.educationaluse }} +

    +

    + + {{ metadata_form.interactivitytype }} +

    +
    + + +
    + + + {{ educational_alignment_form }} + + +
    +

    +
    +
    + {% endcomment %} + {% endif %} +{% endblock %} + +{% block js %} + +{% endblock %} diff --git a/courses/templates/courses/course_sidebar.html b/courses/templates/courses/course_sidebar.html new file mode 100755 index 000000000..2b034ad80 --- /dev/null +++ b/courses/templates/courses/course_sidebar.html @@ -0,0 +1,84 @@ +{% load i18n %} + + + diff --git a/courses/templates/courses/course_user_snip.html b/courses/templates/courses/course_user_snip.html new file mode 100755 index 000000000..f6f07000a --- /dev/null +++ b/courses/templates/courses/course_user_snip.html @@ -0,0 +1,24 @@ + +{% load i18n %} + +
  • +
    {{user.username}}
    + + {{user.username}} + + +
    + {% if can_edit %} +
    + {% csrf_token %} + +
    + {% if user.role == "LEARNER" %} +
    + {% csrf_token %} + +
    + {% endif %} + {% endif %} +
    +
  • diff --git a/courses/templates/courses/create_content.html b/courses/templates/courses/create_content.html new file mode 100755 index 000000000..22b6c35c8 --- /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 100755 index 000000000..9fa0c62c7 --- /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 100755 index 000000000..38545e457 --- /dev/null +++ b/courses/templates/courses/edit_content.html @@ -0,0 +1,68 @@ +{% 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/emails/course_announcement.txt b/courses/templates/courses/emails/course_announcement.txt new file mode 100755 index 000000000..5278aad12 --- /dev/null +++ b/courses/templates/courses/emails/course_announcement.txt @@ -0,0 +1,5 @@ +{% load i18n %} + +{{ announcement_text|safe }} + +{{ _('Manage how often you get these delightful messages at:') }} http://{{domain}}/profile/edit/notifications/ diff --git a/courses/templates/courses/emails/course_announcement_subject.txt b/courses/templates/courses/emails/course_announcement_subject.txt new file mode 100755 index 000000000..437ee1890 --- /dev/null +++ b/courses/templates/courses/emails/course_announcement_subject.txt @@ -0,0 +1,3 @@ +{% load i18n %} + +{% blocktrans with hashtag=course.hashtag %}P2PU Course Announcement #{{ hashtag }}{% endblocktrans %} diff --git a/courses/templates/courses/emails/course_join.txt b/courses/templates/courses/emails/course_join.txt new file mode 100755 index 000000000..1d290a113 --- /dev/null +++ b/courses/templates/courses/emails/course_join.txt @@ -0,0 +1,10 @@ +{% load i18n %} + + +{% trans 'Howdy course organizer!' %} + +{% blocktrans with course_title=course.title%}{{new_user}} signed up for {{course_title}}!{% endblocktrans %} + +{% blocktrans %}Regards +The P2PU team{% endblocktrans %} +{% trans 'Manage how often you get these delightful messages at:' %} http://{{domain}}/profile/edit/notifications/ diff --git a/courses/templates/courses/emails/course_join_subject.txt b/courses/templates/courses/emails/course_join_subject.txt new file mode 100755 index 000000000..bc3b7da3a --- /dev/null +++ b/courses/templates/courses/emails/course_join_subject.txt @@ -0,0 +1,3 @@ +{% load i18n %} + +{% blocktrans with course_title=course.title course_hash=course.hashtag username=new_user %}[{{course_hash}}] {{username}} has signed up for {{course_title}}!{% endblocktrans %} diff --git a/courses/templates/courses/preview_content_snip.html b/courses/templates/courses/preview_content_snip.html new file mode 100755 index 000000000..5c2e9b7c2 --- /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 100755 index 000000000..48daf0fcb --- /dev/null +++ b/courses/tests.py @@ -0,0 +1,200 @@ +from django.test import Client +from django.contrib.auth.models import User +from users.models import create_profile + +from test_utils import TestCase + +from courses import models as course_model +from content import models as content_model +from 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' + + django_user = User( + username=self.test_username, + email=self.test_email, + ) + self.user = create_profile(django_user) + self.user.set_password(self.test_password) + self.user.save() + + django_user2 = User( + username=self.test_username2, + email=self.test_email2, + ) + self.user2 = create_profile(django_user2) + self.user2.set_password(self.test_password2) + self.user2.save() + + + 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") + + # test that a cohort was created + cohort = course_model.get_course_cohort(course['uri']) + self.assertTrue(not cohort == None) + + self.assertTrue( + course_model.user_in_cohort('/uri/user/testuser', cohort['uri']) + ) + self.assertTrue( + course_model.is_cohort_organizer('/uri/user/testuser', cohort['uri']) + ) + + 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_add_content(self): + pass + + def test_add_user(self): + cohort = course_model.get_course_cohort(self.course['uri']) + course_model.add_user_to_cohort( + cohort['uri'], '/uri/user/bob', 'ORGANIZER') + cohort2 = course_model.get_course_cohort(self.course['uri']) + self.assertEqual(len(cohort['users']), len(cohort2['users'])-1) + + def test_remove_user(self): + pass + + def test_make_organizer(self): + pass + + def test_send_course_announcement(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/' + } + ) + with patch('notifications.models.send_notifications_i18n') as send_notification: + course_model.send_course_announcement(course['uri'], 'Notification text') + self.assertTrue(send_notification.called) + #TODO test parameters sent to send_notification + + + 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 = User(username='testuser99', email='99@example.com') + user = create_profile(user) + user.save() + + 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 100755 index 000000000..49efb23ad --- /dev/null +++ b/courses/urls.py @@ -0,0 +1,128 @@ +from django.urls import re_path + +from courses import views + +urlpatterns = [ + re_path(r'^create/$', views.create_course, + name='courses_create'), + + re_path(r'^import_project/(?P[\w-]+)/$', + views.import_project, + name='courses_import_project'), + + 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]+)/learn_api_data/$', + views.course_learn_api_data, + name='courses_learn_api_data'), + + re_path(r'^(?P[\d]+)/admin_content/$', + views.course_admin_content, + name='courses_admin_content'), + + re_path(r'^(?P[\d]+)/discussion/$', + views.course_discussion, + name='courses_discussion'), + + re_path(r'^(?P[\d]+)/people/$', + views.course_people, + name='courses_people'), + + re_path(r'^(?P[\d]+)/settings/$', + views.course_settings, + name='courses_settings'), + + re_path(r'^(?P[\d]+)/announcement/$', + views.course_announcement, + name='courses_announcement'), + + re_path(r'^(?P[\d]+)/export_emails/$', + views.course_export_emails, + name='courses_export_emails'), + + re_path(r'^(?P[\d]+)/badges/$', + views.course_add_badge, + name='course_add_badge'), + + re_path(r'^(?P[\d]+)/signup/$', + views.course_signup, + name='courses_signup'), + + 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'), + + re_path(r'^(?P[\d]+)/add_user/$', + views.course_add_user, + name='courses_add_user'), + + re_path(r'^(?P[\d]+)/change_status/$', + views.course_change_status, + name='courses_change_status'), + + re_path(r'^(?P[\d]+)/change_signup/$', + views.course_change_signup, + name='courses_change_signup'), + + re_path(r'^(?P[\d]+)/change_term/(?Pfixed|rolling)/$', + views.course_change_term, + name='courses_change_term'), + + 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]+)/remove_user/(?P[\w\-\. ]+)/$', + views.course_leave, + name='courses_leave'), + + re_path(r'^(?P[\d]+)/add_organizer/(?P[\w\-\. ]+)/$', + views.course_add_organizer, + name='courses_add_organizer'), + + 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/utils.py b/courses/utils.py new file mode 100755 index 000000000..ad36ed76c --- /dev/null +++ b/courses/utils.py @@ -0,0 +1,35 @@ +import courses.models as course_model +import content.models as content_model +from content import utils as content_utils + +def import_project(project, hashtag): + course = {} + course['title'] = project.name + course['hashtag'] = hashtag + course['description'] = project.short_description + course['language'] = project.language + user_uri = "/uri/user/{0}".format(project.participations.filter(organizing=True).order_by('joined_on')[0].user.username) + course['organizer_uri'] = user_uri + + course = course_model.create_course(**course) + + # update about page + about = { + "uri": course['about_uri'], + "title": "About", + "content": project.long_description, + "author_uri": user_uri, + } + content_model.update_content(**about) + + # add other pages to course + for page in project.pages.filter(deleted=False, listed=True).order_by('index'): + content = { + "title": page.title, + "content": content_utils.clean_user_content(page.content), + "author_uri": "/uri/user/{0}".format(page.author.username), + } + content = content_model.create_content(**content) + course_model.add_course_content(course['uri'], content['uri']) + + return course diff --git a/courses/views.py b/courses/views.py new file mode 100755 index 000000000..cc9dce377 --- /dev/null +++ b/courses/views.py @@ -0,0 +1,706 @@ +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 CourseTermForm +from courses.forms import CourseImageForm +from courses.forms import CourseStatusForm +from courses.forms import CohortSignupForm +from courses.forms import CourseTagsForm +from courses.forms import CourseEmbeddedUrlForm +from courses.decorators import require_organizer +from courses.badges_oembed import add_content_from_response +from courses.badges_oembed import BadgeNotFoundException + +from content import models as content_model +from content.forms import ContentForm + +#from learn import models as learn_model +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.ResourceDeletedException: + # raise http. + except: + # TODO: this masks all exceptions that may happen in get_course! + 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']) + + #NOTE if performance becomes a problem dont fetch cohort + cohort = course_model.get_course_cohort(course_uri) + context['cohort'] = cohort + user_uri = u"/uri/user/{0}".format(request.user.username) + context['organizer'] = course_model.is_cohort_organizer( + user_uri, cohort['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 course_model.user_in_cohort(user_uri, cohort['uri']): + if not context['organizer']: + context['show_leave_course'] = True + context['learner'] = True + elif cohort['signup'] == "OPEN": + context['show_signup'] = True + + #try: + # course_lists = learn_model.get_lists_for_course(reverse( + # 'courses_slug_redirect', + # kwargs={'course_id': course_id} + # )) + # f = lambda l: l['name'] not in ['drafts', 'listed', 'archived'] + # context['lists'] = filter(f, course_lists) + #except: + # log.error("Could not get lists for course!") + + 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) + + +def import_project( request, project_slug ): + from projects.models import Project + project = get_object_or_404(Project, slug=project_slug) + from courses import utils + course = utils.import_project(project, project.name[:3]) + cohort = course_model.get_course_cohort(course['uri']) + user_uri = u"/uri/user/{0}".format(request.user.username) + course_model.add_user_to_cohort(cohort['uri'], user_uri, "ORGANIZER") + return course_slug_redirect(request, course['id']) + + +@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 = { } + context = _populate_course_context(request, course_id, context) + 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) + + +def course_learn_api_data( request, course_id ): + """ return data required by the learn API """ + course_uri = course_model.course_id2uri(course_id) + try: + course_data = course_model.get_course_learn_api_data(course_uri) + except: + raise http.Http404 + + return http.HttpResponse(json.dumps(course_data), mimetype="application/json") + + +@login_required +@require_organizer +def course_add_badge( request, course_id ): + context = { } + context = _populate_course_context(request, course_id, context) + context['badges_active'] = True + user = request.user + + form = CourseEmbeddedUrlForm() + + if request.method == "POST": + form = CourseEmbeddedUrlForm(request.POST) + + if form.is_valid(): + content = None + user_uri = u"/uri/user/{0}".format(user.username) + try: + content = add_content_from_response( + context['course']['uri'], + form.cleaned_data['url'], user_uri) + except BadgeNotFoundException: + form = CourseEmbeddedUrlForm() + messages.error(request, _('Error! We could not retrieve this Badge')) + if content: + redirect_url = reverse('courses_content_show', + kwargs={'course_id': course_id, + 'content_id': content['id']}) + return http.HttpResponseRedirect(redirect_url) + + context['form'] = form + return render(request, 'courses/course_badges.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 + ) + + +def course_discussion( 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['discussion_active'] = True + + return render( + request, + 'courses/course_discussion.html', + context + ) + + +def course_people( 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) + + from users.models import get_user_profile_image_url + for user in context['cohort']['users'].values(): + user['profile_image_url'] = get_user_profile_image_url(user['uri']) + + context['people_active'] = True + + return render( + request, + 'courses/course_people.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}) + if context['cohort']['term'] == 'FIXED': + context['term_form'] = CourseTermForm(context['cohort']) + else: + context['term_form'] = CourseTermForm() + context['signup_form'] = CohortSignupForm( + initial={'signup': context['cohort']['signup']} + ) + + 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 +def course_signup( request, course_id ): + #NOTE: consider using cohort_id in URL to avoid cohort lookup + cohort = course_model.get_course_cohort( course_id ) + user_uri = u"/uri/user/{0}".format(request.user.username) + if cohort['signup'] == "OPEN": + course_model.add_user_to_cohort(cohort['uri'], user_uri, "LEARNER", True) + messages.success(request, _("You are now signed up for this course.")) + else: + messages.error(request, _("This course isn't open for signup.")) + return course_slug_redirect( request, course_id ) + + +@login_required +@require_http_methods(['POST']) +@require_organizer +def course_add_user( request, course_id ): + cohort_uri = course_model.get_course_cohort_uri(course_id) + redirect_url = reverse('courses_people', kwargs={'course_id': course_id}) + username = request.POST.get('username', None) + + if not username: + messages.error(request, _("Please select a user to add.")) + return http.HttpResponseRedirect(redirect_url) + + user_uri = u"/uri/user/{0}".format(username) + try: + course_model.add_user_to_cohort(cohort_uri, user_uri, "LEARNER") + messages.success(request, _("User added.")) + except course_model.ResourceNotFoundException as e: + messages.error(request, _("User does not exist.")) + + return http.HttpResponseRedirect(redirect_url) + + +@login_required +def course_leave( request, course_id, username ): + cohort_uri = course_model.get_course_cohort_uri(course_id) + user_uri = u"/uri/user/{0}".format(request.user.username) + # TODO site admin should also be able to remove users + is_organizer = course_model.is_cohort_organizer( + user_uri, cohort_uri + ) + removed = False + error_message = _("Could not remove user") + if username == request.user.username or is_organizer: + removed, error_message = course_model.remove_user_from_cohort( + cohort_uri, u"/uri/user/{0}".format(username) + ) + + if not removed: + messages.error(request, error_message) + + if is_organizer: + redirect_url = reverse('courses_people', kwargs={'course_id': course_id}) + return http.HttpResponseRedirect(redirect_url) + + return course_slug_redirect( request, course_id) + + +@login_required +@require_http_methods(['POST']) +@require_organizer +def course_add_organizer( request, course_id, username ): + cohort_uri = course_model.get_course_cohort_uri(course_id) + user_uri = u"/uri/user/{0}".format(request.user.username) + is_organizer = course_model.is_cohort_organizer( + user_uri, cohort_uri + ) + if not is_organizer and not request.user.is_superuser: + messages.error( request, _("Only other organizers can add a new organizer") ) + return course_slug_redirect( request, course_id) + new_organizer_uri = u"/uri/user/{0}".format(username) + course_model.remove_user_from_cohort(cohort_uri, new_organizer_uri) + course_model.add_user_to_cohort(cohort_uri, new_organizer_uri, "ORGANIZER") + + #TODO + redirect_url = reverse('courses_people', kwargs={'course_id': course_id}) + return http.HttpResponseRedirect(redirect_url) + + +@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_change_signup( request, course_id ): + form = CohortSignupForm(request.POST) + if form.is_valid(): + signup = form.cleaned_data['signup'] + cohort_uri = course_model.get_course_cohort_uri(course_id) + cohort = course_model.update_cohort(cohort_uri, signup=signup.upper()) + if not cohort: + messages.error( request, _("Could not change cohort signup")) + else: + request.messages.error(request, _("Invalid choice for signup")) + 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_change_term( request, course_id, term ): + cohort_uri = course_model.get_course_cohort_uri( course_id ) + if term == 'fixed': + form = CourseTermForm(request.POST) + if form.is_valid(): + course_model.update_cohort( + cohort_uri, + term=term.upper(), + start_date=form.cleaned_data['start_date'], + end_date=form.cleaned_data['end_date'] + ) + else: + messages.error( request, _("Could not update fixed term dates")) + elif term == 'rolling': + course_model.update_cohort(cohort_uri, term=term.upper()) + 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) + + +@login_required +@require_organizer +def course_announcement( request, course_id ): + context = _populate_course_context(request, course_id, {}) + context['announcement_active'] = True + + if request.method == "POST" and len(request.POST.get('announcement_text', '')) > 0: + text = request.POST.get('announcement_text') + course_model.send_course_announcement( + context['course']['uri'], + text + ) + messages.success(request, _('The announcement has been sent!')) + redirect_url = reverse('courses_show', kwargs={'course_id': course_id, 'slug': context['course']['slug']}) + return http.HttpResponseRedirect(redirect_url) + + return render( + request, + 'courses/course_announcement.html', + context + ) + + +@login_required +@require_organizer +def course_export_emails( request, course_id ): + if not request.user.has_perm('users.trusted_user'): + msg = _('You do not have permission to view this page') + return http.HttpResponseForbidden(msg) + + course_uri = course_model.course_id2uri(course_id) + cohort = course_model.get_course_cohort(course_uri) + + response = http.HttpResponse(mimetype='text/csv') + response['Content-Disposition'] = 'attachment; ' + response['Content-Disposition'] += 'filename=detailed_report.csv' + writer = unicodecsv.writer(response) + writer.writerow(["username", "email address", "signup date"]) + + for user in cohort['users'].values(): + username = user['uri'].strip('/').split('/')[-1] + user['email'] = User.objects.get(username=username).email + writer.writerow([username, user['email'], user['signup_date']]) + + return response + + +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 = request.POST.get('next_url', None) + if not redirect_url: + 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) + if request.GET.get('next_url', None): + context['next_url'] = request.GET.get('next_url', None) + + 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 = request.POST.get('next_url', None) + if not redirect_url: + 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) + if request.GET.get('next_url', None): + context['next_url'] = request.GET.get('next_url', None) + 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/learnwithpeople/settings.py b/learnwithpeople/settings.py index f087db366..9e0932365 100644 --- a/learnwithpeople/settings.py +++ b/learnwithpeople/settings.py @@ -64,6 +64,9 @@ 'community_calendar', 'client_logging', 'contact', + 'content', + 'courses', + 'media', ] MIDDLEWARE = [ diff --git a/learnwithpeople/urls.py b/learnwithpeople/urls.py index 2758bed49..845a19be4 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'^course/', include('courses.urls')), re_path(r'^', include('studygroups.urls')) ) diff --git a/media/__init__.py b/media/__init__.py new file mode 100755 index 000000000..e69de29bb diff --git a/media/db.py b/media/db.py new file mode 100755 index 000000000..df06cbd19 --- /dev/null +++ b/media/db.py @@ -0,0 +1,7 @@ +from django.db import models + + +class Image(models.Model): + + image_file = models.FileField(upload_to="uploads/images") + uploader_uri = models.CharField(max_length=256) diff --git a/media/migrations/0001_initial.py b/media/migrations/0001_initial.py new file mode 100644 index 000000000..ce6017040 --- /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 000000000..e69de29bb diff --git a/media/models.py b/media/models.py new file mode 100755 index 000000000..d99732444 --- /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 From 439975a9be80247ac5c13559a6f7bcf59cbcba88 Mon Sep 17 00:00:00 2001 From: Dirk Uys Date: Thu, 27 Nov 2025 14:13:59 +0200 Subject: [PATCH 02/63] Add content to learning circles --- content/templatetags/content_tags.py | 5 +- .../templates/courses/content_form_snip.html | 6 +- courses/templates/courses/edit_content.html | 4 +- learnwithpeople/urls.py | 2 +- ...e_topic_guides_alter_courselist_courses.py | 23 +++ .../0173_studygroup_course_content.py | 20 +++ studygroups/models/learningcircle.py | 1 + studygroups/urls.py | 3 + studygroups/views/learner.py | 30 ++++ templates/studygroups/content_view.html | 150 ++++++++++++++++++ 10 files changed, 236 insertions(+), 8 deletions(-) create mode 100644 studygroups/migrations/0172_alter_course_topic_guides_alter_courselist_courses.py create mode 100644 studygroups/migrations/0173_studygroup_course_content.py create mode 100644 templates/studygroups/content_view.html diff --git a/content/templatetags/content_tags.py b/content/templatetags/content_tags.py index fac61cf78..088b680ea 100755 --- a/content/templatetags/content_tags.py +++ b/content/templatetags/content_tags.py @@ -1,5 +1,6 @@ from django import template from django.template.defaultfilters import stringfilter +from django.utils.safestring import mark_safe import markdown import bleach @@ -12,7 +13,7 @@ def add_target_blank(attrs, new=False): attrs[(None, 'target')] = '_blank' return attrs -@register.filter +@register.filter(is_safe=True) @stringfilter def convert_content( markdown_text ): html = markdown.markdown(markdown_text)#TODO , ['tables']) @@ -22,6 +23,6 @@ def convert_content( markdown_text ): regex = re.compile(r'.*?', re.IGNORECASE|re.DOTALL) f = lambda m: re.sub(r'&', '&', m.group(0)) html = regex.sub(f, html) - return html + return mark_safe(html) convert_content.is_safe = True diff --git a/courses/templates/courses/content_form_snip.html b/courses/templates/courses/content_form_snip.html index d13c413e8..be4333eb4 100755 --- a/courses/templates/courses/content_form_snip.html +++ b/courses/templates/courses/content_form_snip.html @@ -17,13 +17,13 @@

    {{ form.title.errors }} - +

    {{ form.content.errors }}

    - +

    {% blocktrans %}You can use markdown for your content. For some help on using markdown, look @@ -37,7 +37,7 @@

    -
    +
    {% comment %} {% include 'learn/markdown_cheatsheet.html' %} {% endcomment %} diff --git a/courses/templates/courses/edit_content.html b/courses/templates/courses/edit_content.html index 38545e457..aedd0f4c9 100755 --- a/courses/templates/courses/edit_content.html +++ b/courses/templates/courses/edit_content.html @@ -25,11 +25,11 @@

    {% block content %}
    -