From 7e273f7b65adbb481306c7489124a9c653def7b3 Mon Sep 17 00:00:00 2001 From: gfawcett Date: Mon, 2 Mar 2009 04:09:18 +0000 Subject: [PATCH] Admin options for editing terms, deparments: generic indexes and forms. The big change in this rev is introduction of the Django 'newforms' system. Since we're using Genshi (not Django templating), newforms seemed a bad fit. But I think I've got a fairly nice compromise between Django and Genshi going on in my generics.py. See the ModelForm subclasses in views.py: the Index attribute is novel, and specific to the generics.py system. There is still more to do here, notably figuring out how to handle permissions and i18n. git-svn-id: svn://svn.open-ils.org/ILS-Contrib/servres/trunk@133 6d9bc8c9-1ec2-4278-b937-99fde70a366f --- conifer/genshi_namespace.py | 8 ++ conifer/static/main.css | 3 + conifer/syrup/generics.py | 53 +++++++++++ conifer/syrup/models.py | 2 +- conifer/syrup/on_courses.txt | 161 +++++++++++++++++++++++++++++++++ conifer/syrup/urls.py | 9 +- conifer/syrup/views.py | 47 ++++++++++ conifer/templates/admin/index.xhtml | 18 ++++ conifer/templates/admin/term.xhtml | 18 ++++ conifer/templates/generic/delete.xhtml | 24 +++++ conifer/templates/generic/edit.xhtml | 23 +++++ conifer/templates/generic/index.xhtml | 28 ++++++ conifer/templates/tabbar.xhtml | 1 + 13 files changed, 393 insertions(+), 2 deletions(-) create mode 100644 conifer/syrup/generics.py create mode 100644 conifer/syrup/on_courses.txt create mode 100644 conifer/templates/admin/index.xhtml create mode 100644 conifer/templates/admin/term.xhtml create mode 100644 conifer/templates/generic/delete.xhtml create mode 100644 conifer/templates/generic/edit.xhtml create mode 100644 conifer/templates/generic/index.xhtml diff --git a/conifer/genshi_namespace.py b/conifer/genshi_namespace.py index c4acd81..e0bcf87 100644 --- a/conifer/genshi_namespace.py +++ b/conifer/genshi_namespace.py @@ -9,3 +9,11 @@ from conifer.syrup import models # this probably ought to be a method on User, or another model class. def instructor_url(instructor, suffix=''): return '/syrup/instructor/%d/%s' % (instructor.id, suffix) + + +def call_or_value(obj, dflt=None): + # This is used by the generics templates. + if callable(obj): + return obj() or dflt + else: + return obj or dflt diff --git a/conifer/static/main.css b/conifer/static/main.css index b2eaaf1..fd6a3c9 100644 --- a/conifer/static/main.css +++ b/conifer/static/main.css @@ -189,3 +189,6 @@ p.todo, div.todo { background-color: #fdd; padding: 6; margin: 12; border-left: #coursebanner h1 { padding: 0; font-size: 110%; } .breadcrumbs { margin: 8 8 8 0; } + +.errorlist { float: right; } +.errorlist li { color: red; font-size: 90%; } \ No newline at end of file diff --git a/conifer/syrup/generics.py b/conifer/syrup/generics.py new file mode 100644 index 0000000..5b81962 --- /dev/null +++ b/conifer/syrup/generics.py @@ -0,0 +1,53 @@ +import conifer.genshi_support as g +from django.http import HttpResponse, HttpResponseRedirect +from django.http import HttpResponseForbidden +from django.shortcuts import get_object_or_404 +from django.forms import ModelForm, ValidationError + +def generic_index(form): + assert hasattr(form, 'Index') + return g.render('generic/index.xhtml', form=form) + +def generic_edit(form, request, obj_id): + if obj_id == '0': + instance = None + else: + instance = get_object_or_404(form.Meta.model, pk=obj_id) + if request.method == 'GET': + form = form(instance=instance) + return g.render('generic/edit.xhtml', **locals()) + else: + form = form(request.POST, instance=instance) + if not form.is_valid(): + return g.render('generic/edit.xhtml', **locals()) + else: + form.save() + return HttpResponseRedirect('../') + +def generic_delete(form, request, obj_id): + instance = get_object_or_404(models.Term, pk=obj_id) + if request.method != 'POST': + form = form(instance=instance) + return g.render('generic/delete.xhtml', **locals()) + else: + instance.delete() + return HttpResponseRedirect('../') + +def generic_handler(form): + def handler(request, obj_id=None, action=None): + if obj_id is None and action is None: + return generic_index(form) + elif action is None: + return generic_edit(form, request, obj_id) + elif action == 'delete': + return generic_delete(form, request, obj_id) + return handler + + +def strip_and_nonblank(fieldname): + def clean(self): + v = self.cleaned_data.get(fieldname) or '' + if not v.strip(): + raise ValidationError('Cannot be blank.') + return v.strip() + return clean diff --git a/conifer/syrup/models.py b/conifer/syrup/models.py index 1059b0b..db829c4 100644 --- a/conifer/syrup/models.py +++ b/conifer/syrup/models.py @@ -127,7 +127,7 @@ class ServiceDesk(m.Model): # TERMS, COURSES, MEMBERSHIP class Term(m.Model): - code = m.CharField(max_length=16, blank=True, null=True) + code = m.CharField(max_length=16, blank=True, null=True, unique=True) name = m.CharField(max_length=255) start = m.DateField() finish = m.DateField() diff --git a/conifer/syrup/on_courses.txt b/conifer/syrup/on_courses.txt new file mode 100644 index 0000000..95dd89e --- /dev/null +++ b/conifer/syrup/on_courses.txt @@ -0,0 +1,161 @@ +# -*- mode: text; mode: auto-fill; -*- + + +Fleshing out the course model +-------------------------------------------------- + +These are just some stream-of-thought notes; don't take them too +seriously yet. + + + +We should capitalize on external data sources for course and +registration information. At the same time, it must be possible to +use Syrup without an external source, or in a mixed mode (where some +courses are defined externally and others are ad-hoc). + +There will be local variations in the quality of course information. +Possibly some can provide lists of known courses, but not registration +information; others will know students enrolled but may not know +instructors (or vice versa). + +So, in fact we have a number of granular external sources, and Syrup +should operate with any combination of them. + +* list of terms (but allowing ad-hoc terms); + +* list of course codes and titles (not time- or term-related); + +* list of offerings (course-code offered in term); + +* list of sections (same course offered several times in one term, + and/or broken up into multiple subgroups. Some reserve courses may + aggregate some sections, excluding others). Sections are ultimately + the join-points between instructors and students, so we must handle + them well; + +* cross-listings (equivalent course-codes); + +* people (username/identifier, given, surname, email) + +* instructor and student relationships (fred teaches FRE233/2009W/01; + bill takes ESP125/2009W/03). + + +Rolls-Royce Scenario +-------------------------------------------------- + +Shelley Smith wants to set up a reserves-site for the course she's +teaching next term. She logs into reserves, clicks on My Courses, and +clicks Add a New Course Site. The form asks her to pick a term (the +current term is the default, all future terms are listed in a +drop-down), and to pick a course. The system suggests the courses she +is currently teaching; but this is for a future term, and she's not +the instructor of record yet, so she picks the code and title from a +drop-down list. + +She doesn't specify any sections, since she won't know that for a few +months yet. The site remains unavailable to students until she does. + +Later, she clicks on Invite Students. It asks her to pick the +course-sections she's teaching from a list. It knows her course is +cross-listed; the cross-list sections also appear on the list. + +Once the sections are selected, and she presses Continue, the students +are granted access. + + Variation: staff-assistance + -------------------------------------------------- + + Shelley contacts Ed at the Reserves desk, and tells him she wants to + put some items on reserve. She's teaching MAC-100, Intro to Macrame, + section 2, in the upcoming term. + + Ed clicks on Assist, and finds Shelley's in the people-list, and + selects her. He clicks on Shelley's Courses, then Add New Course, + and enters the term and picks MAC-100 from the list. He knows the + section so is able to add that right away. The system alerts him to + the cross-listed section, and he adds that too. + + The site won't be ready for students until he (or Shelley) activates + the site later on. + + Shelley gets an email letting her know the site is ready for + content. + + Data sources + -------------------------------------------------- + + Shelley and Ed logged in against campus LDAP. + + Shelley's current courses and + + + +Another take +------------------------------------------------------------ + +Shelley can set up the course well in advance, with a trivial working +title, e.g. "Macrame". It's one of Shelley's courses, but no one else +has access, and it's not associated with any term, etc. + + Instructor: course-add + Library-Staff: course-add + +Later, when Shelley is ready to open her reserves to her students, she +opens the site and hits Invite. What appears is a list of the course +sections she is teaching: + + MAC-100 section 02 -- Introduction to Macrame (58 members) + MAC-301 section 01 -- Macrame Advanced Studio (2 members) + LAB-203 section 01 -- Macrame in Labour Studies (8 members) + +She checkmarks the first and last section, and presses Invite. +Automatically, her site is available to her students, but it also gets +a proper title and course number: she is asked to pick either MAC-100 +or LAB-203 as the primary identifier for the site; the other becomes +an alias. + +Later calls Ed -- she's talked with the instructor of Textiles 101, +and they've agreed to share Shelley's reserves list with the Textiles +class. She cannot add an arbitrary section to the course site. So Ed +opens the site; clicks on Invite; clicks Arbitrary Section; and +specifies the term, course code, and section of the Textiles class. + + Instructor: course-section-invite + Library-Staff: course-section-invite-arbitrary + +A key point here is that the formal course numbers may not be +selectable until late in the game; until just before the course, a +working title may be all that you have. That's fine. Choosing specific +course sections can be deferred until the registration info is +available. + + +What about the case where there is no registration data? How do you +make a site available to an unknown audience? + +Have controls for "publishing" a site, that is, making it available to +unknown users. A few options: + + * Anyone at all can view the site, even anonymously + + * Any logged-in user can view the site + + * The instructor provides an access key out-of-band. The access key + auto-invites you into the site. This isn't so bad really. + + * Any logged-in user can request access; the instructor has to grant + access. This way lies madness. + +Scrapping the madness-path, we're left with a reasonable set of +options: Anonymous, Authenticated, AccessKey, Restricted (to members +of the registered list), or NoAccess (for archived sites, etc.). None +of this applies to instructors and their proxies, just to visitors. + + Instructor: course-change-access + Library-Staff: course-change-access-extended + + The extended permissions could let the librarian decide if certain + types of materials in the site should be inaccessible to Anonymous + users. Or maybe we should just scrap Anonymous access altogether. diff --git a/conifer/syrup/urls.py b/conifer/syrup/urls.py index b06b376..c5e21f8 100644 --- a/conifer/syrup/urls.py +++ b/conifer/syrup/urls.py @@ -4,7 +4,7 @@ from django.conf.urls.defaults import * # want to cut down on the common boilerplate in the urlpatterns below. ITEM_PREFIX = r'^course/(?P\d+)/item/(?P\d+)/' - +GENERIC_REGEX = r'((?P\d+)/)?(?P.+)?$' urlpatterns = patterns('conifer.syrup.views', (r'^$', 'welcome'), @@ -24,4 +24,11 @@ urlpatterns = patterns('conifer.syrup.views', (ITEM_PREFIX + r'meta$', 'item_metadata'), (ITEM_PREFIX + r'edit/$', 'item_edit'), (ITEM_PREFIX + r'add/$', 'item_add'), # for adding sub-things + (r'^admin/$', 'admin_index'), + (r'^admin/terms/' + GENERIC_REGEX, 'admin_terms'), + (r'^admin/depts/' + GENERIC_REGEX, 'admin_depts'), + +# (r'^admin/terms/(?P\d+)/$', 'admin_term_edit'), +# (r'^admin/terms/(?P\d+)/delete$', 'admin_term_delete'), +# (r'^admin/terms/$', 'admin_term'), ) diff --git a/conifer/syrup/views.py b/conifer/syrup/views.py index ab5b6fa..ffb423e 100644 --- a/conifer/syrup/views.py +++ b/conifer/syrup/views.py @@ -10,6 +10,7 @@ from conifer.syrup import models from django.contrib.auth.models import User from django.db.models import Q from datetime import datetime +from generics import * #------------------------------------------------------------ # Authentication @@ -415,3 +416,49 @@ def search(request, in_course=None): return g.render('search_results.xhtml', **locals()) + +#------------------------------------------------------------ +# administrative options + +def admin_index(request): + return g.render('admin/index.xhtml') + +# fixme, no auth or permissions stuff yet. + +class TermForm(ModelForm): + class Meta: + model = models.Term + + class Index: + title = 'Terms' + all = models.Term.objects.order_by('start', 'code').all + cols = ['code', 'name', 'start', 'finish'] + links = [0,1] + + clean_name = strip_and_nonblank('name') + clean_code = strip_and_nonblank('code') + + def clean(self): + cd = self.cleaned_data + s, f = cd.get('start'), cd.get('finish') + if (s and f) and s >= f: + raise ValidationError, 'start must precede finish' + return cd + +admin_terms = generic_handler(TermForm) + +class DeptForm(ModelForm): + class Meta: + model = models.Department + + class Index: + title = 'Departments' + all = models.Department.objects.order_by('abbreviation').all + cols = ['abbreviation', 'name'] + links = [0,1] + + clean_abbreviation = strip_and_nonblank('abbreviation') + clean_name = strip_and_nonblank('name') + +admin_depts = generic_handler(DeptForm) + diff --git a/conifer/templates/admin/index.xhtml b/conifer/templates/admin/index.xhtml new file mode 100644 index 0000000..a4a7150 --- /dev/null +++ b/conifer/templates/admin/index.xhtml @@ -0,0 +1,18 @@ + + + + + ${title} + + +

${title}

+ + + diff --git a/conifer/templates/admin/term.xhtml b/conifer/templates/admin/term.xhtml new file mode 100644 index 0000000..de7341e --- /dev/null +++ b/conifer/templates/admin/term.xhtml @@ -0,0 +1,18 @@ + + + + + ${title} + + +

${title}

+
+ ${Markup(form.as_table())}
+ +
+ + diff --git a/conifer/templates/generic/delete.xhtml b/conifer/templates/generic/delete.xhtml new file mode 100644 index 0000000..5b72f65 --- /dev/null +++ b/conifer/templates/generic/delete.xhtml @@ -0,0 +1,24 @@ + + + + + ${title} + + +

${title}

+
+ + + + + +
${form.Meta.model.__name__}${instance}
+

+

Cancel

+
+ + diff --git a/conifer/templates/generic/edit.xhtml b/conifer/templates/generic/edit.xhtml new file mode 100644 index 0000000..20235af --- /dev/null +++ b/conifer/templates/generic/edit.xhtml @@ -0,0 +1,23 @@ + + + + + ${title} + + +

${title}

+
+ ${Markup(form.as_table())}
+

+

Cancel changes

+

Delete this record

+
+ + diff --git a/conifer/templates/generic/index.xhtml b/conifer/templates/generic/index.xhtml new file mode 100644 index 0000000..ecab82f --- /dev/null +++ b/conifer/templates/generic/index.xhtml @@ -0,0 +1,28 @@ + + + + + ${title} + + +

${title}

+ + + + + + + +
${c}
+ ${call_or_value(getattr(r,c))} + +
+

Add ${form.Meta.model.__name__}

+ + diff --git a/conifer/templates/tabbar.xhtml b/conifer/templates/tabbar.xhtml index f1d48c6..6d1eb26 100644 --- a/conifer/templates/tabbar.xhtml +++ b/conifer/templates/tabbar.xhtml @@ -10,6 +10,7 @@
  • Home
  • Browse
  • My Courses
  • +
  • Admin Options