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" %}
+
+
+ Please enable JavaScript to view the comments powered by Disqus.
+ comments powered by
+
+ {% 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 %}
+
+
+
+
+
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 %}
+
+ {{ task.title }}
+ {% if organizer %}
+ {% if not forloop.first %} {% endif %}
+ {% if not forloop.last %} {% endif %}
+ {% endif %}
+
+ {% if organizer %}
+
+ {% csrf_token %}
+ {% trans "remove" %}
+
+
+
+
+ {% endif %}
+
+ {% endfor %}
+
+
+ {% 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 %}
+ {% trans 'Announcement text:' %}
+
+ {% trans "Send" %}
+
+
+{% 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 'Insert the URL' %}
+
{% blocktrans %}
+ Grab the unique URL for your Badge from
+
+ badges.p2pu.org
+ and insert it here.'
+ {% endblocktrans %}
+
+
+ {% csrf_token %}
+
+ {% trans "Badge URL:"%} ?
+ {{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 %}
+
+
+
+
+
+
+
+
+{% 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 %}
+
+
+ {{ _('Delete Course') }}
+
+
+ {% csrf_token %}
+
+ {{ _('Delete') }}
+ {{ _('Back') }}
+
+
+
+
+
+{% 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 %}
+
+
+
+ Please enable JavaScript to view the comments powered by Disqus.
+ comments powered by
+
+{% 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" %}
+
+ {% for user in cohort.users.values %}
+ {% if user.role == "LEARNER" %}
+ {% include "courses/course_user_snip.html" %}
+ {% endif %}
+ {% endfor %}
+
+
+
+
{% trans "Organizers" %}
+
+ {% for user in cohort.users.values %}
+ {% if user.role == "ORGANIZER" %}
+ {% include "courses/course_user_snip.html" %}
+ {% endif %}
+ {% endfor %}
+
+
+ {% if can_edit %}
+
+
+ {% csrf_token %}
+
+ {% trans "Add user" %}
+
+
+ {% 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 %}
+ {% trans "Course status" %}
+ {{ status_form.status }}
+ {% trans "Save" %}
+
+
+ {% else %}
+
+
+ {% csrf_token %}
+
+ {% trans "Course Title" %}
+ {{ update_form.title }}
+
+ {% trans "Save" %}
+
+
+
+
+
+ {% csrf_token %}
+
+ {% trans "Hashtag" %}
+ {{ update_form.hashtag }}
+
+ {% trans "Save" %}
+
+
+
+
+
+
+
+ {% csrf_token %}
+ {{image_form.image}}
+ {% trans 'Upload new image' %}
+
+
+
+
+
+
+ {% csrf_token %}
+ {% trans "Description" %}
+ {{ update_form.description }}
+ {% trans "Update description" %}
+
+
+
+
+
+ {% csrf_token %}
+ {% trans "Course status" %}
+ {{ status_form.status }}
+ {% trans "Save" %}
+
+
+
+
+ {% 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 %}
+ {% trans "Start date" %} {{term_form.start_date}}
+ {% trans "End 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 }}
+ {% trans "Save" %}
+
+
+
+
+
+
+ {% csrf_token %}
+ {% trans "Tags" %}
+ {{ tags_form.tags }}
+ {% trans "Save" %}
+
+
+
+
+
+
+ {% csrf_token %}
+ {% trans "Course languge" %}
+ {{ update_form.language }}
+ {% trans "Update language" %}
+
+
+
+
+ {% comment %}
+
+ {% 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}}
+
+
+
+
+
+ {% if can_edit %}
+
+ {% csrf_token %}
+ {% trans "Remove user" %}
+
+ {% if user.role == "LEARNER" %}
+
+ {% csrf_token %}
+ {% trans "Make organizer" %}
+
+ {% 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 %}
+
+{% 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 %}
+
+ {% trans "Course Title:" %}
+ {{form.title.errors}}
+
+
+
+
+ {% trans "Hashtag:"%} ?
+ {{form.hashtag.errors}}
+
+
+
+
+
+ {% trans "Description:" %} ?
+ {{form.description.errors}}
+ {{form.description.value|default_if_none:""}}
+
+
+ Language: ?
+ {{ form.language }}
+
+ {% trans "Create my course!" %}
+
+
+
+{% 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 %}
+
+{% 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 @@
{% trans "Title" %}
{{ form.title.errors }}
-
+
{% trans "Content" %}
{{ form.content.errors }}
- {{form.content.value|default_if_none:""}}
+ {{form.content.value|default_if_none:""}}
{% 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 %}
-