Merge lp:~ivle-dev/ivle/submissions into lp:ivle
- submissions
- Merge into trunk
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 |
Related bugs: |
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.
Description of the change
To post a comment you must log in.
Revision history for this message
William Grant (wgrant) wrote : | # |
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 < 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 ( |
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.