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