Merge lp:~ivle-dev/ivle/submissions into lp:ivle

Proposed by William Grant
Status: Merged
Merged at revision: 1174
Proposed branch: lp:~ivle-dev/ivle/submissions
Merge into: lp:ivle
Diff against target: None lines
To merge this branch: bzr merge lp:~ivle-dev/ivle/submissions
Reviewer Review Type Date Requested Status
Nick Chadwick Approve
Matt Giuca Approve
Review via email: mp+5239@code.launchpad.net

Commit message

Allow students to submit projects from personal or group repositories.

To post a comment you must log in.
Revision history for this message
William Grant (wgrant) wrote :

This is the first bit of the student end of the submission functionality. It only includes creation of submissions - viewing them is out of scope.

Revision history for this message
Matt Giuca (mgiuca) wrote :

Code looks good to me. Haven't run it though.

review: Approve
Revision history for this message
Nick Chadwick (chadnickbok) wrote :

I approve.

I dislike that there is an extension object in our database, yet this object is not respected.

review: Approve
lp:~ivle-dev/ivle/submissions updated
1210. By William Grant

Remove the NOT NULL and default from project_set.max_students_per_group.

NULL now means it's a solo project - 0 means there is no limit.

1211. By William Grant

Respect the new max_students_per_group semantics in Python.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'bin/ivle-config'
2--- bin/ivle-config 2009-03-03 23:19:52 +0000
3+++ bin/ivle-config 2009-03-26 05:33:03 +0000
4@@ -387,6 +387,7 @@
5 [ivle.webapp.tos#Plugin]
6 [ivle.webapp.userservice#Plugin]
7 [ivle.webapp.fileservice#Plugin]
8+[ivle.webapp.submit#Plugin]
9 """)
10 plugindefault.close()
11 print "Successfully wrote %s" % plugindefaultfile
12
13=== modified file 'ivle/database.py'
14--- ivle/database.py 2009-03-17 01:42:19 +0000
15+++ ivle/database.py 2009-04-06 10:21:22 +0000
16@@ -38,6 +38,7 @@
17 'User',
18 'Subject', 'Semester', 'Offering', 'Enrolment',
19 'ProjectSet', 'Project', 'ProjectGroup', 'ProjectGroupMembership',
20+ 'Assessed', 'ProjectSubmission', 'ProjectExtension',
21 'Exercise', 'Worksheet', 'WorksheetExercise',
22 'ExerciseSave', 'ExerciseAttempt',
23 'TestCase', 'TestSuite', 'TestSuiteVar'
24@@ -117,6 +118,10 @@
25 return self.hash_password(password) == self.passhash
26
27 @property
28+ def display_name(self):
29+ return self.fullname
30+
31+ @property
32 def password_expired(self):
33 fieldval = self.pass_exp
34 return fieldval is not None and datetime.datetime.now() > fieldval
35@@ -184,6 +189,29 @@
36 '''A sanely ordered list of all of the user's enrolments.'''
37 return self._get_enrolments(False)
38
39+ def get_projects(self, offering=None, active_only=True):
40+ '''Return Projects that the user can submit.
41+
42+ This will include projects for offerings in which the user is
43+ enrolled, as long as the project is not in a project set which has
44+ groups (ie. if maximum number of group members is 0).
45+
46+ Unless active_only is False, only projects for active offerings will
47+ be returned.
48+
49+ If an offering is specified, returned projects will be limited to
50+ those for that offering.
51+ '''
52+ return Store.of(self).find(Project,
53+ Project.project_set_id == ProjectSet.id,
54+ ProjectSet.max_students_per_group == 0,
55+ ProjectSet.offering_id == Offering.id,
56+ (offering is None) or (Offering.id == offering.id),
57+ Semester.id == Offering.semester_id,
58+ (not active_only) or (Semester.state == u'current'),
59+ Enrolment.offering_id == Offering.id,
60+ Enrolment.user_id == self.id)
61+
62 @staticmethod
63 def hash_password(password):
64 return md5.md5(password).hexdigest()
65@@ -198,7 +226,7 @@
66
67 def get_permissions(self, user):
68 if user and user.admin or user is self:
69- return set(['view', 'edit'])
70+ return set(['view', 'edit', 'submit_project'])
71 else:
72 return set()
73
74@@ -363,18 +391,53 @@
75 __storm_table__ = "project"
76
77 id = Int(name="projectid", primary=True)
78+ name = Unicode()
79+ short_name = Unicode()
80 synopsis = Unicode()
81 url = Unicode()
82 project_set_id = Int(name="projectsetid")
83 project_set = Reference(project_set_id, ProjectSet.id)
84 deadline = DateTime()
85
86+ assesseds = ReferenceSet(id, 'Assessed.project_id')
87+ submissions = ReferenceSet(id,
88+ 'Assessed.project_id',
89+ 'Assessed.id',
90+ 'ProjectSubmission.assessed_id')
91+
92 __init__ = _kwarg_init
93
94 def __repr__(self):
95- return "<%s '%s' in %r>" % (type(self).__name__, self.synopsis,
96+ return "<%s '%s' in %r>" % (type(self).__name__, self.short_name,
97 self.project_set.offering)
98
99+ def can_submit(self, principal):
100+ return (self in principal.get_projects() and
101+ self.deadline > datetime.datetime.now())
102+
103+ def submit(self, principal, path, revision, who):
104+ """Submit a Subversion path and revision to a project.
105+
106+ 'principal' is the owner of the Subversion repository, and the
107+ entity on behalf of whom the submission is being made. 'path' is
108+ a path within that repository, and 'revision' specifies which
109+ revision of that path. 'who' is the person making the submission.
110+ """
111+
112+ if not self.can_submit(principal):
113+ raise Exception('cannot submit')
114+
115+ a = Assessed.get(Store.of(self), principal, self)
116+ ps = ProjectSubmission()
117+ ps.path = path
118+ ps.revision = revision
119+ ps.date_submitted = datetime.datetime.now()
120+ ps.assessed = a
121+ ps.submitter = who
122+
123+ return ps
124+
125+
126 class ProjectGroup(Storm):
127 __storm_table__ = "project_group"
128
129@@ -398,6 +461,39 @@
130 return "<%s %s in %r>" % (type(self).__name__, self.name,
131 self.project_set.offering)
132
133+ @property
134+ def display_name(self):
135+ return '%s (%s)' % (self.nick, self.name)
136+
137+ def get_projects(self, offering=None, active_only=True):
138+ '''Return Projects that the group can submit.
139+
140+ This will include projects in the project set which owns this group,
141+ unless the project set disallows groups (in which case none will be
142+ returned).
143+
144+ Unless active_only is False, projects will only be returned if the
145+ group's offering is active.
146+
147+ If an offering is specified, projects will only be returned if it
148+ matches the group's.
149+ '''
150+ return Store.of(self).find(Project,
151+ Project.project_set_id == ProjectSet.id,
152+ ProjectSet.id == self.project_set.id,
153+ ProjectSet.max_students_per_group > 0,
154+ ProjectSet.offering_id == Offering.id,
155+ (offering is None) or (Offering.id == offering.id),
156+ Semester.id == Offering.semester_id,
157+ (not active_only) or (Semester.state == u'current'))
158+
159+
160+ def get_permissions(self, user):
161+ if user.admin or user in self.members:
162+ return set(['submit_project'])
163+ else:
164+ return set()
165+
166 class ProjectGroupMembership(Storm):
167 __storm_table__ = "group_member"
168 __storm_primary__ = "user_id", "project_group_id"
169@@ -413,6 +509,72 @@
170 return "<%s %r in %r>" % (type(self).__name__, self.user,
171 self.project_group)
172
173+class Assessed(Storm):
174+ __storm_table__ = "assessed"
175+
176+ id = Int(name="assessedid", primary=True)
177+ user_id = Int(name="loginid")
178+ user = Reference(user_id, User.id)
179+ project_group_id = Int(name="groupid")
180+ project_group = Reference(project_group_id, ProjectGroup.id)
181+
182+ project_id = Int(name="projectid")
183+ project = Reference(project_id, Project.id)
184+
185+ extensions = ReferenceSet(id, 'ProjectExtension.assessed_id')
186+ submissions = ReferenceSet(id, 'ProjectSubmission.assessed_id')
187+
188+ def __repr__(self):
189+ return "<%s %r in %r>" % (type(self).__name__,
190+ self.user or self.project_group, self.project)
191+
192+ @classmethod
193+ def get(cls, store, principal, project):
194+ t = type(principal)
195+ if t not in (User, ProjectGroup):
196+ raise AssertionError('principal must be User or ProjectGroup')
197+
198+ a = store.find(cls,
199+ (t is User) or (cls.project_group_id == principal.id),
200+ (t is ProjectGroup) or (cls.user_id == principal.id),
201+ Project.id == project.id).one()
202+
203+ if a is None:
204+ a = cls()
205+ if t is User:
206+ a.user = principal
207+ else:
208+ a.project_group = principal
209+ a.project = project
210+ store.add(a)
211+
212+ return a
213+
214+
215+class ProjectExtension(Storm):
216+ __storm_table__ = "project_extension"
217+
218+ id = Int(name="extensionid", primary=True)
219+ assessed_id = Int(name="assessedid")
220+ assessed = Reference(assessed_id, Assessed.id)
221+ deadline = DateTime()
222+ approver_id = Int(name="approver")
223+ approver = Reference(approver_id, User.id)
224+ notes = Unicode()
225+
226+class ProjectSubmission(Storm):
227+ __storm_table__ = "project_submission"
228+
229+ id = Int(name="submissionid", primary=True)
230+ assessed_id = Int(name="assessedid")
231+ assessed = Reference(assessed_id, Assessed.id)
232+ path = Unicode()
233+ revision = Int()
234+ submitter_id = Int(name="submitter")
235+ submitter = Reference(submitter_id, User.id)
236+ date_submitted = DateTime()
237+
238+
239 # WORKSHEETS AND EXERCISES #
240
241 class Exercise(Storm):
242
243=== modified file 'ivle/date.py'
244--- ivle/date.py 2009-03-26 09:20:56 +0000
245+++ ivle/date.py 2009-03-31 05:53:01 +0000
246@@ -66,3 +66,62 @@
247 # Else, include the year (mmm dd, yyyy)
248 else:
249 return dt.strftime("%b %d, %Y")
250+
251+def format_datetime_for_paragraph(datetime_or_seconds):
252+ """Generate a compact representation of a datetime for use in a paragraph.
253+
254+ Given a datetime or number of seconds elapsed since the epoch, generates
255+ a compact string representing the date and time in human-readable form.
256+
257+ Unlike make_date_nice_short, the time will always be included.
258+
259+ Also unlike make_date_nice_short, it is suitable for use in the middle of
260+ a block of prose and properly handles timestamps in the future nicely.
261+ """
262+
263+ dt = get_datetime(datetime_or_seconds)
264+ now = datetime.datetime.now()
265+
266+ delta = dt - now
267+
268+ # If the date is earlier than now, we want to either say something like
269+ # '5 days ago' or '25 seconds ago', 'yesterday at 08:54' or
270+ # 'on 2009-03-26 at 20:09'.
271+
272+ # If the time is within one hour of now, we show it nicely in either
273+ # minutes or seconds.
274+
275+ if abs(delta).days == 0 and abs(delta).seconds <= 1:
276+ return 'now'
277+
278+ if abs(delta).days == 0 and abs(delta).seconds < 60*60:
279+ if abs(delta) == delta:
280+ # It's in the future.
281+ prefix = 'in '
282+ suffix = ''
283+ else:
284+ prefix = ''
285+ suffix = ' ago'
286+
287+ # Show the number of minutes unless we are within two minutes.
288+ if abs(delta).seconds >= 120:
289+ return (prefix + '%d minutes' + suffix) % (abs(delta).seconds / 60)
290+ else:
291+ return (prefix + '%d seconds' + suffix) % (abs(delta).seconds)
292+
293+ if dt < now:
294+ if dt.date() == now.date():
295+ # Today.
296+ return dt.strftime('today at %I:%M %p')
297+ elif dt.date() == now.date() - datetime.timedelta(days=1):
298+ # Yesterday.
299+ return dt.strftime('yesterday at %I:%M %p')
300+ elif dt > now:
301+ if dt.date() == now.date():
302+ # Today.
303+ return dt.strftime('today at %I:%M %p')
304+ elif dt.date() == now.date() + datetime.timedelta(days=1):
305+ # Tomorrow
306+ return dt.strftime('tomorrow at %I:%M %p')
307+
308+ return dt.strftime('on %Y-%m-%d at %I:%M %p')
309
310=== modified file 'ivle/fileservice_lib/__init__.py'
311--- ivle/fileservice_lib/__init__.py 2009-02-18 05:14:29 +0000
312+++ ivle/fileservice_lib/__init__.py 2009-04-06 08:24:32 +0000
313@@ -80,6 +80,8 @@
314
315 import urllib
316
317+import cjson
318+
319 import ivle.fileservice_lib.action
320 import ivle.fileservice_lib.listing
321
322@@ -102,12 +104,19 @@
323 fields = req.get_fieldstorage()
324 if req.method == 'POST':
325 act = fields.getfirst('action')
326-
327+
328+ out = None
329+
330 if act is not None:
331 try:
332- ivle.fileservice_lib.action.handle_action(req, act, fields)
333+ out = ivle.fileservice_lib.action.handle_action(req, act, fields)
334 except action.ActionError, message:
335 req.headers_out['X-IVLE-Action-Error'] = urllib.quote(str(message))
336
337- return_type = fields.getfirst('return')
338- ivle.fileservice_lib.listing.handle_return(req, return_type == "contents")
339+ if out:
340+ req.content_type = 'application/json'
341+ req.write(cjson.encode(out))
342+ else:
343+ return_type = fields.getfirst('return')
344+ ivle.fileservice_lib.listing.handle_return(req,
345+ return_type == "contents")
346
347=== modified file 'ivle/fileservice_lib/action.py'
348--- ivle/fileservice_lib/action.py 2009-01-22 04:02:36 +0000
349+++ ivle/fileservice_lib/action.py 2009-04-06 08:39:35 +0000
350@@ -197,7 +197,7 @@
351 except KeyError:
352 # Default, just send an error but then continue
353 raise ActionError("Unknown action")
354- action(req, fields)
355+ return action(req, fields)
356
357 def actionpath_to_urlpath(req, path):
358 """Determines the URL path (relative to the student home) upon which the
359@@ -738,15 +738,22 @@
360 def action_svnrepostat(req, fields):
361 """Discovers whether a path exists in a repo under the IVLE SVN root.
362
363+ If it does exist, returns a dict containing its metadata.
364+
365 Reads fields: 'path'
366 """
367 path = fields.getfirst('path')
368 url = ivle.conf.svn_addr + "/" + path
369- svnclient.exception_style = 1
370+ svnclient.exception_style = 1
371
372 try:
373 svnclient.callback_get_login = get_login
374- svnclient.info2(url)
375+ info = svnclient.info2(url,
376+ revision=pysvn.Revision(pysvn.opt_revision_kind.head))[0][1]
377+ return {'svnrevision': info['rev'].number
378+ if info['rev'] and
379+ info['rev'].kind == pysvn.opt_revision_kind.number
380+ else None}
381 except pysvn.ClientError, e:
382 # Error code 170000 means ENOENT in this revision.
383 if e[1][0][1] == 170000:
384
385=== modified file 'ivle/fileservice_lib/listing.py'
386--- ivle/fileservice_lib/listing.py 2009-01-22 02:14:14 +0000
387+++ ivle/fileservice_lib/listing.py 2009-04-02 06:38:57 +0000
388@@ -327,6 +327,15 @@
389 filename = os.path.basename(fullpath)
390 text_status = status.text_status
391 d = {'svnstatus': str(text_status)}
392+
393+ if status.entry is not None:
394+ d.update({
395+ 'svnurl': status.entry.url,
396+ 'svnrevision': status.entry.revision.number
397+ if status.entry.revision.kind == pysvn.opt_revision_kind.number
398+ else None,
399+ })
400+
401 try:
402 d.update(_fullpath_stat_fileinfo(fullpath))
403 except OSError:
404
405=== modified file 'ivle/webapp/base/ivle-headings.html'
406--- ivle/webapp/base/ivle-headings.html 2009-03-25 04:29:35 +0000
407+++ ivle/webapp/base/ivle-headings.html 2009-04-02 07:08:27 +0000
408@@ -13,6 +13,7 @@
409 <script py:if="not publicmode and write_javascript_settings" type="text/javascript">
410 root_dir = "${root_dir}";
411 public_host = "${public_host}";
412+ svn_base = "${svn_base}";
413 username = "${login}";
414 </script>
415
416
417=== modified file 'ivle/webapp/base/xhtml.py'
418--- ivle/webapp/base/xhtml.py 2009-03-17 04:40:20 +0000
419+++ ivle/webapp/base/xhtml.py 2009-04-02 07:08:27 +0000
420@@ -103,6 +103,7 @@
421 ctx['favicon'] = None
422 ctx['root_dir'] = ivle.conf.root_dir
423 ctx['public_host'] = ivle.conf.public_host
424+ ctx['svn_base'] = ivle.conf.svn_addr
425 ctx['write_javascript_settings'] = req.write_javascript_settings
426 if req.user:
427 ctx['login'] = req.user.login
428
429=== modified file 'ivle/webapp/filesystem/browser/__init__.py'
430--- ivle/webapp/filesystem/browser/__init__.py 2009-02-25 07:47:58 +0000
431+++ ivle/webapp/filesystem/browser/__init__.py 2009-04-02 07:12:43 +0000
432@@ -135,7 +135,7 @@
433 ('Publishing', True, [
434 ('publish', ['Publish', 'Make it so this directory can be seen by anyone on the web']),
435 ('share', ['Share this file', 'Get a link to this published file, to give to friends']),
436- ('submit', ['Submit', 'Submit the selected files for an assignment'])
437+ ('submit', ['Submit', 'Submit the selected files for a project'])
438 ]),
439 ('File actions', True, [
440 ('rename', ['Rename', 'Change the name of this file']),
441
442=== modified file 'ivle/webapp/filesystem/browser/media/browser.js'
443--- ivle/webapp/filesystem/browser/media/browser.js 2009-03-25 11:25:11 +0000
444+++ ivle/webapp/filesystem/browser/media/browser.js 2009-04-06 09:26:16 +0000
445@@ -781,6 +781,15 @@
446 /* Log should be available for revisions as well. */
447 set_action_state("svnlog", single_versioned_path, true);
448
449+ single_ivle_versioned_path = (
450+ (
451+ (numsel == 1 && (stat = file_listing[selected_files[0]])) ||
452+ (numsel == 0 && (stat = current_file))
453+ ) && stat.svnstatus != "unversioned"
454+ && stat.svnurl
455+ && stat.svnurl.substr(0, svn_base.length) == svn_base);
456+ set_action_state(["submit"], single_ivle_versioned_path);
457+
458 /* There is currently nothing on the More Actions menu of use
459 * when the current file is not a directory. Hence, just remove
460 * it entirely.
461@@ -838,8 +847,25 @@
462 window.open(public_app_path("~" + current_path, filename), 'share')
463 break;
464 case "submit":
465- // TODO
466- alert("Not yet implemented: Submit");
467+ if (selected_files.length == 1)
468+ stat = file_listing[selected_files[0]];
469+ else
470+ stat = current_file;
471+ path = stat.svnurl.substr(svn_base.length);
472+
473+ /* The working copy might not have an up-to-date version of the
474+ * directory. While submitting like this could yield unexpected
475+ * results, we should really submit the latest revision to minimise
476+ * terrible mistakes - so we run off and ask fileservice for the
477+ * latest revision.*/
478+ $.post(app_path(service_app, current_path),
479+ {"action": "svnrepostat", "path": path},
480+ function(result)
481+ {
482+ window.location = path_join(app_path('+submit'), path) + '?revision=' + result.svnrevision;
483+ },
484+ "json");
485+
486 break;
487 case "rename":
488 action_rename(filename);
489
490=== added directory 'ivle/webapp/submit'
491=== added file 'ivle/webapp/submit/__init__.py'
492--- ivle/webapp/submit/__init__.py 1970-01-01 00:00:00 +0000
493+++ ivle/webapp/submit/__init__.py 2009-04-06 10:30:09 +0000
494@@ -0,0 +1,147 @@
495+# IVLE
496+# Copyright (C) 2007-2009 The University of Melbourne
497+#
498+# This program is free software; you can redistribute it and/or modify
499+# it under the terms of the GNU General Public License as published by
500+# the Free Software Foundation; either version 2 of the License, or
501+# (at your option) any later version.
502+#
503+# This program is distributed in the hope that it will be useful,
504+# but WITHOUT ANY WARRANTY; without even the implied warranty of
505+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
506+# GNU General Public License for more details.
507+#
508+# You should have received a copy of the GNU General Public License
509+# along with this program; if not, write to the Free Software
510+# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
511+
512+# Author: Will Grant
513+
514+"""Project submissions user interface."""
515+
516+import os.path
517+import datetime
518+
519+from storm.locals import Store
520+
521+from ivle.database import (User, ProjectGroup, Offering, Subject, Semester,
522+ ProjectSet, Project, Enrolment)
523+from ivle.webapp.errors import NotFound, BadRequest
524+from ivle.webapp.base.xhtml import XHTMLView
525+from ivle.webapp.base.plugins import ViewPlugin
526+
527+import ivle.date
528+
529+
530+class SubmitView(XHTMLView):
531+ """A view to submit a Subversion repository path for a project."""
532+ template = 'submit.html'
533+ tab = 'files'
534+ permission = 'submit_project'
535+
536+ def __init__(self, req, name, path):
537+ # We need to work out which entity owns the repository, so we look
538+ # at the first two path segments. The first tells us the type.
539+ self.context = self.get_repository_owner(req.store, name)
540+ self.path = os.path.normpath(path)
541+
542+ if self.context is None:
543+ raise NotFound()
544+
545+ self.offering = self.get_offering()
546+
547+ def get_repository_owner(self, store, name):
548+ """Return the owner of the repository given the name and a Store."""
549+ raise NotImplementedError()
550+
551+ def get_offering(self):
552+ """Return the offering that this path can be submitted to."""
553+ raise NotImplementedError()
554+
555+ def populate(self, req, ctx):
556+ if req.method == 'POST':
557+ data = dict(req.get_fieldstorage())
558+ if 'revision' not in data:
559+ raise BadRequest('No revision selected.')
560+
561+ try:
562+ revision = int(data['revision'])
563+ except ValueError:
564+ raise BadRequest('Revision must be an integer.')
565+
566+ if 'project' not in data:
567+ raise BadRequest('No project selected.')
568+
569+ try:
570+ projectid = int(data['project'])
571+ except ValueError:
572+ raise BadRequest('Project must be an integer.')
573+
574+ project = req.store.find(Project, Project.id == projectid).one()
575+
576+ # This view's offering will be the sole offering for which the
577+ # path is permissible. We need to check that.
578+ if project.project_set.offering is not self.offering:
579+ raise BadRequest('Path is not permissible for this offering')
580+
581+ if project is None:
582+ raise BadRequest('Specified project does not exist')
583+
584+ project.submit(self.context, self.path, revision, req.user)
585+
586+ self.template = 'submitted.html'
587+ ctx['project'] = project
588+
589+ ctx['req'] = req
590+ ctx['principal'] = self.context
591+ ctx['offering'] = self.offering
592+ ctx['path'] = self.path
593+ ctx['now'] = datetime.datetime.now()
594+ ctx['format_datetime'] = ivle.date.format_datetime_for_paragraph
595+
596+
597+class UserSubmitView(SubmitView):
598+ def get_repository_owner(self, store, name):
599+ '''Resolve the user name into a user.'''
600+ return User.get_by_login(store, name)
601+
602+ def get_offering(self):
603+ return Store.of(self.context).find(Offering,
604+ Offering.id == Enrolment.offering_id,
605+ Enrolment.user_id == self.context.id,
606+ Offering.semester_id == Semester.id,
607+ Semester.state == u'current',
608+ Offering.subject_id == Subject.id,
609+ Subject.short_name == self.path.split('/')[0],
610+ ).one()
611+
612+
613+class GroupSubmitView(SubmitView):
614+ def get_repository_owner(self, store, name):
615+ '''Resolve the subject_year_semester_group name into a group.'''
616+ namebits = name.split('_', 3)
617+ if len(namebits) != 4:
618+ return None
619+
620+ # Find the project group with the given name in any project set in the
621+ # offering of the given subject in the semester with the given year
622+ # and semester.
623+ return store.find(ProjectGroup,
624+ ProjectGroup.name == namebits[3],
625+ ProjectGroup.project_set_id == ProjectSet.id,
626+ ProjectSet.offering_id == Offering.id,
627+ Offering.subject_id == Subject.id,
628+ Subject.short_name == namebits[0],
629+ Offering.semester_id == Semester.id,
630+ Semester.year == namebits[1],
631+ Semester.semester == namebits[2]).one()
632+
633+ def get_offering(self):
634+ return self.context.project_set.offering
635+
636+
637+class Plugin(ViewPlugin):
638+ urls = [
639+ ('+submit/users/:name/*path', UserSubmitView),
640+ ('+submit/groups/:name/*path', GroupSubmitView),
641+ ]
642
643=== added file 'ivle/webapp/submit/submit.html'
644--- ivle/webapp/submit/submit.html 1970-01-01 00:00:00 +0000
645+++ ivle/webapp/submit/submit.html 2009-04-06 10:30:09 +0000
646@@ -0,0 +1,43 @@
647+<html xmlns="http://www.w3.org/1999/xhtml"
648+ xmlns:py="http://genshi.edgewall.org/">
649+ <head>
650+ <title>Submit project</title>
651+ </head>
652+ <body>
653+ <h1>Submit project</h1>
654+ <div id="ivle_padding">
655+ <py:choose test="offering">
656+ <div py:when="None">
657+ <p>You may not submit files from <span style="font-family: monospace">${path}</span> in
658+ <py:choose test="">
659+ <span py:when="principal is req.user">your repository.</span>
660+ <span py:otherwise="">the repository for ${principal.display_name}.</span>
661+ </py:choose>
662+ You can only submit files from a subject directory.
663+ </p>
664+ </div>
665+ <div py:otherwise="">
666+ <p>You are submitting <span style="font-family: monospace">${path}</span> from
667+ <py:choose test="">
668+ <span py:when="principal is req.user">your repository.</span>
669+ <span py:otherwise="">the repository for ${principal.display_name}.</span>
670+ </py:choose>
671+ </p>
672+ <p>You may submit to any open project in ${offering.subject.name}. Which project do you wish to submit this for?</p>
673+ <form action="" method="post">
674+ <ul style="list-style: none">
675+ <li py:for="project in principal.get_projects(offering=offering)"
676+ py:with="attrs = {'disabled': 'disabled'} if project.deadline &lt; now else {}">
677+ <input type="radio" name="project" id="project_${project.id}" value="${project.id}" py:attrs="attrs" />
678+ <label for="project_${project.id}">${project.name} - <em>${project.synopsis}</em> - (due ${format_datetime(project.deadline)})</label>
679+ </li>
680+ </ul>
681+ <p>Ensure that you have committed all changes - only changes in the repository will be submitted.</p>
682+ <p>You may resubmit a project again at any time until its deadline.</p>
683+ <p><input type="submit" value="Submit Project" /></p>
684+ </form>
685+ </div>
686+ </py:choose>
687+ </div>
688+ </body>
689+</html>
690
691=== added file 'ivle/webapp/submit/submitted.html'
692--- ivle/webapp/submit/submitted.html 1970-01-01 00:00:00 +0000
693+++ ivle/webapp/submit/submitted.html 2009-04-06 10:30:09 +0000
694@@ -0,0 +1,12 @@
695+<html xmlns="http://www.w3.org/1999/xhtml"
696+ xmlns:py="http://genshi.edgewall.org/">
697+ <head>
698+ <title>Project submitted</title>
699+ </head>
700+ <body>
701+ <h1>Project submitted</h1>
702+ <div id="ivle_padding">
703+ <p>You successfully submitted ${project.name} for ${offering.subject.name}.</p>
704+ </div>
705+ </body>
706+</html>
707
708=== added file 'userdb/migrations/20090406-01.sql'
709--- userdb/migrations/20090406-01.sql 1970-01-01 00:00:00 +0000
710+++ userdb/migrations/20090406-01.sql 2009-04-06 10:41:54 +0000
711@@ -0,0 +1,40 @@
712+BEGIN;
713+
714+ALTER TABLE project ALTER COLUMN synopsis TYPE TEXT;
715+ALTER TABLE project ALTER COLUMN url TYPE TEXT;
716+ALTER TABLE project ADD COLUMN short_name TEXT NOT NULL;
717+ALTER TABLE project ADD COLUMN name TEXT NOT NULL;
718+
719+CREATE OR REPLACE FUNCTION check_project_namespacing_insertupdate()
720+RETURNS trigger AS '
721+ DECLARE
722+ oid INTEGER;
723+ BEGIN
724+ IF TG_OP = ''UPDATE'' THEN
725+ IF NEW.projectsetid = OLD.projectsetid AND NEW.short_name = OLD.short_name THEN
726+ RETURN NEW;
727+ END IF;
728+ END IF;
729+ SELECT offeringid INTO oid FROM project_set WHERE project_set.projectsetid = NEW.projectsetid;
730+ PERFORM 1 FROM project, project_set
731+ WHERE project_set.offeringid = oid AND
732+ project.projectsetid = project_set.projectsetid AND
733+ project.short_name = NEW.short_name;
734+ IF found THEN
735+ RAISE EXCEPTION ''a project named % already exists in offering ID %'', NEW.short_name, oid;
736+ END IF;
737+ RETURN NEW;
738+ END;
739+' LANGUAGE 'plpgsql';
740+
741+CREATE TRIGGER check_project_namespacing
742+ BEFORE INSERT OR UPDATE ON project
743+ FOR EACH ROW EXECUTE PROCEDURE check_project_namespacing_insertupdate();
744+
745+ALTER TABLE project_extension ADD COLUMN extensionid SERIAL PRIMARY KEY;
746+
747+ALTER TABLE project_submission ADD COLUMN submissionid SERIAL PRIMARY KEY;
748+ALTER TABLE project_submission ADD COLUMN date_submitted TIMESTAMP NOT NULL;
749+ALTER TABLE project_submission ADD COLUMN submitter INT4 REFERENCES login (loginid) NOT NULL;
750+
751+COMMIT;
752
753=== modified file 'userdb/users.sql'
754--- userdb/users.sql 2009-02-25 04:44:54 +0000
755+++ userdb/users.sql 2009-04-06 10:21:22 +0000
756@@ -61,12 +61,40 @@
757
758 CREATE TABLE project (
759 projectid SERIAL PRIMARY KEY NOT NULL,
760- synopsis VARCHAR,
761- url VARCHAR,
762+ short_name TEXT NOT NULL,
763+ name TEXT NOT NULL,
764+ synopsis TEXT,
765+ url TEXT,
766 projectsetid INTEGER REFERENCES project_set (projectsetid) NOT NULL,
767 deadline TIMESTAMP
768 );
769
770+CREATE OR REPLACE FUNCTION check_project_namespacing_insertupdate()
771+RETURNS trigger AS '
772+ DECLARE
773+ oid INTEGER;
774+ BEGIN
775+ IF TG_OP = ''UPDATE'' THEN
776+ IF NEW.projectsetid = OLD.projectsetid AND NEW.short_name = OLD.short_name THEN
777+ RETURN NEW;
778+ END IF;
779+ END IF;
780+ SELECT offeringid INTO oid FROM project_set WHERE project_set.projectsetid = NEW.projectsetid;
781+ PERFORM 1 FROM project, project_set
782+ WHERE project_set.offeringid = oid AND
783+ project.projectsetid = project_set.projectsetid AND
784+ project.short_name = NEW.short_name;
785+ IF found THEN
786+ RAISE EXCEPTION ''a project named % already exists in offering ID %'', NEW.short_name, oid;
787+ END IF;
788+ RETURN NEW;
789+ END;
790+' LANGUAGE 'plpgsql';
791+
792+CREATE TRIGGER check_project_namespacing
793+ BEFORE INSERT OR UPDATE ON project
794+ FOR EACH ROW EXECUTE PROCEDURE check_project_namespacing_insertupdate();
795+
796 CREATE TABLE project_group (
797 groupnm VARCHAR NOT NULL,
798 groupid SERIAL PRIMARY KEY NOT NULL,
799@@ -135,6 +163,7 @@
800 );
801
802 CREATE TABLE project_extension (
803+ extensionid SERIAL PRIMARY KEY,
804 assessedid INT4 REFERENCES assessed (assessedid) NOT NULL,
805 deadline TIMESTAMP NOT NULL,
806 approver INT4 REFERENCES login (loginid) NOT NULL,
807@@ -142,9 +171,12 @@
808 );
809
810 CREATE TABLE project_submission (
811+ submissionid SERIAL PRIMARY KEY,
812 assessedid INT4 REFERENCES assessed (assessedid) NOT NULL,
813 path VARCHAR NOT NULL,
814- revision INT4 NOT NULL
815+ revision INT4 NOT NULL,
816+ date_submitted TIMESTAMP NOT NULL,
817+ submitter INT4 REFERENCES login (loginid) NOT NULL
818 );
819
820 CREATE TABLE project_mark (

Subscribers

People subscribed via source and target branches