oops, uses Alejandro's fp_verification_key. my bad.
[mediagoblin.git] / mediagoblin / db / models.py
1 # GNU MediaGoblin -- federated, autonomous media hosting
2 # Copyright (C) 2011 Free Software Foundation, Inc
3 #
4 # This program is free software: you can redistribute it and/or modify
5 # it under the terms of the GNU Affero General Public License as published by
6 # the Free Software Foundation, either version 3 of the License, or
7 # (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU Affero General Public License for more details.
13 #
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16
17 import datetime, uuid
18
19 from mongokit import Document
20
21 from mediagoblin import util
22 from mediagoblin.auth import lib as auth_lib
23 from mediagoblin import mg_globals
24 from mediagoblin.db import migrations
25 from mediagoblin.db.util import ASCENDING, DESCENDING, ObjectId
26 from mediagoblin.util import Pagination
27 from mediagoblin.util import DISPLAY_IMAGE_FETCHING_ORDER
28
29
30 ###################
31 # Custom validators
32 ###################
33
34 ########
35 # Models
36 ########
37
38
39 class User(Document):
40 """
41 A user of MediaGoblin.
42
43 Structure:
44 - username: The username of this user, should be unique to this instance.
45 - email: Email address of this user
46 - created: When the user was created
47 - plugin_data: a mapping of extra plugin information for this User.
48 Nothing uses this yet as we don't have plugins, but someday we
49 might... :)
50 - pw_hash: Hashed version of user's password.
51 - email_verified: Whether or not the user has verified their email or not.
52 Most parts of the site are disabled for users who haven't yet.
53 - status: whether or not the user is active, etc. Currently only has two
54 values, 'needs_email_verification' or 'active'. (In the future, maybe
55 we'll change this to a boolean with a key of 'active' and have a
56 separate field for a reason the user's been disabled if that's
57 appropriate... email_verified is already separate, after all.)
58 - verification_key: If the user is awaiting email verification, the user
59 will have to provide this key (which will be encoded in the presented
60 URL) in order to confirm their email as active.
61 - is_admin: Whether or not this user is an administrator or not.
62 - url: this user's personal webpage/website, if appropriate.
63 - bio: biography of this user (plaintext, in markdown)
64 - bio_html: biography of the user converted to proper HTML.
65 """
66 __collection__ = 'users'
67
68 structure = {
69 'username': unicode,
70 'email': unicode,
71 'created': datetime.datetime,
72 'plugin_data': dict, # plugins can dump stuff here.
73 'pw_hash': unicode,
74 'email_verified': bool,
75 'status': unicode,
76 'verification_key': unicode,
77 'is_admin': bool,
78 'url' : unicode,
79 'bio' : unicode, # May contain markdown
80 'bio_html': unicode, # May contain plaintext, or HTML
81 'fp_verification_key': unicode, # forgotten password verification key
82 'fp_token_expire': datetime.datetime
83 }
84
85 required_fields = ['username', 'created', 'pw_hash', 'email']
86
87 default_values = {
88 'created': datetime.datetime.utcnow,
89 'email_verified': False,
90 'status': u'needs_email_verification',
91 'verification_key': lambda: unicode(uuid.uuid4()),
92 'is_admin': False}
93
94 def check_login(self, password):
95 """
96 See if a user can login with this password
97 """
98 return auth_lib.bcrypt_check_password(
99 password, self['pw_hash'])
100
101
102 class MediaEntry(Document):
103 """
104 Record of a piece of media.
105
106 Structure:
107 - uploader: A reference to a User who uploaded this.
108
109 - title: Title of this work
110
111 - slug: A normalized "slug" which can be used as part of a URL to retrieve
112 this work, such as 'my-works-name-in-slug-form' may be viewable by
113 'http://mg.example.org/u/username/m/my-works-name-in-slug-form/'
114 Note that since URLs are constructed this way, slugs must be unique
115 per-uploader. (An index is provided to enforce that but code should be
116 written on the python side to ensure this as well.)
117
118 - created: Date and time of when this piece of work was uploaded.
119
120 - description: Uploader-set description of this work. This can be marked
121 up with MarkDown for slight fanciness (links, boldness, italics,
122 paragraphs...)
123
124 - description_html: Rendered version of the description, run through
125 Markdown and cleaned with our cleaning tool.
126
127 - media_type: What type of media is this? Currently we only support
128 'image' ;)
129
130 - media_data: Extra information that's media-format-dependent.
131 For example, images might contain some EXIF data that's not appropriate
132 to other formats. You might store it like:
133
134 mediaentry['media_data']['exif'] = {
135 'manufacturer': 'CASIO',
136 'model': 'QV-4000',
137 'exposure_time': .659}
138
139 Alternately for video you might store:
140
141 # play length in seconds
142 mediaentry['media_data']['play_length'] = 340
143
144 ... so what's appropriate here really depends on the media type.
145
146 - plugin_data: a mapping of extra plugin information for this User.
147 Nothing uses this yet as we don't have plugins, but someday we
148 might... :)
149
150 - tags: A list of tags. Each tag is stored as a dictionary that has a key
151 for the actual name and the normalized name-as-slug, so ultimately this
152 looks like:
153 [{'name': 'Gully Gardens',
154 'slug': 'gully-gardens'},
155 {'name': 'Castle Adventure Time?!",
156 'slug': 'castle-adventure-time'}]
157
158 - state: What's the state of this file? Active, inactive, disabled, etc...
159 But really for now there are only two states:
160 "unprocessed": uploaded but needs to go through processing for display
161 "processed": processed and able to be displayed
162
163 - queued_media_file: storage interface style filepath describing a file
164 queued for processing. This is stored in the mg_globals.queue_store
165 storage system.
166
167 - queued_task_id: celery task id. Use this to fetch the task state.
168
169 - media_files: Files relevant to this that have actually been processed
170 and are available for various types of display. Stored like:
171 {'thumb': ['dir1', 'dir2', 'pic.png'}
172
173 - attachment_files: A list of "attachment" files, ones that aren't
174 critical to this piece of media but may be usefully relevant to people
175 viewing the work. (currently unused.)
176
177 - fail_error: path to the exception raised
178 - fail_metadata:
179 """
180 __collection__ = 'media_entries'
181
182 structure = {
183 'uploader': ObjectId,
184 'title': unicode,
185 'slug': unicode,
186 'created': datetime.datetime,
187 'description': unicode, # May contain markdown/up
188 'description_html': unicode, # May contain plaintext, or HTML
189 'media_type': unicode,
190 'media_data': dict, # extra data relevant to this media_type
191 'plugin_data': dict, # plugins can dump stuff here.
192 'tags': [dict],
193 'state': unicode,
194
195 # For now let's assume there can only be one main file queued
196 # at a time
197 'queued_media_file': [unicode],
198 'queued_task_id': unicode,
199
200 # A dictionary of logical names to filepaths
201 'media_files': dict,
202
203 # The following should be lists of lists, in appropriate file
204 # record form
205 'attachment_files': list,
206
207 # If things go badly in processing things, we'll store that
208 # data here
209 'fail_error': unicode,
210 'fail_metadata': dict}
211
212 required_fields = [
213 'uploader', 'created', 'media_type', 'slug']
214
215 default_values = {
216 'created': datetime.datetime.utcnow,
217 'state': u'unprocessed'}
218
219 def get_comments(self):
220 return self.db.MediaComment.find({
221 'media_entry': self['_id']}).sort('created', DESCENDING)
222
223 def get_display_media(self, media_map, fetch_order=DISPLAY_IMAGE_FETCHING_ORDER):
224 """
225 Find the best media for display.
226
227 Args:
228 - media_map: a dict like
229 {u'image_size': [u'dir1', u'dir2', u'image.jpg']}
230 - fetch_order: the order we should try fetching images in
231
232 Returns:
233 (media_size, media_path)
234 """
235 media_sizes = media_map.keys()
236
237 for media_size in DISPLAY_IMAGE_FETCHING_ORDER:
238 if media_size in media_sizes:
239 return media_map[media_size]
240
241 def main_mediafile(self):
242 pass
243
244 def generate_slug(self):
245 self['slug'] = util.slugify(self['title'])
246
247 duplicate = mg_globals.database.media_entries.find_one(
248 {'slug': self['slug']})
249
250 if duplicate:
251 self['slug'] = "%s-%s" % (self['_id'], self['slug'])
252
253 def url_for_self(self, urlgen):
254 """
255 Generate an appropriate url for ourselves
256
257 Use a slug if we have one, else use our '_id'.
258 """
259 uploader = self.uploader()
260
261 if self.get('slug'):
262 return urlgen(
263 'mediagoblin.user_pages.media_home',
264 user=uploader['username'],
265 media=self['slug'])
266 else:
267 return urlgen(
268 'mediagoblin.user_pages.media_home',
269 user=uploader['username'],
270 media=unicode(self['_id']))
271
272 def url_to_prev(self, urlgen):
273 """
274 Provide a url to the previous entry from this user, if there is one
275 """
276 cursor = self.db.MediaEntry.find({'_id' : {"$gt": self['_id']},
277 'uploader': self['uploader'],
278 'state': 'processed'}).sort(
279 '_id', ASCENDING).limit(1)
280 if cursor.count():
281 return urlgen('mediagoblin.user_pages.media_home',
282 user=self.uploader()['username'],
283 media=unicode(cursor[0]['slug']))
284
285 def url_to_next(self, urlgen):
286 """
287 Provide a url to the next entry from this user, if there is one
288 """
289 cursor = self.db.MediaEntry.find({'_id' : {"$lt": self['_id']},
290 'uploader': self['uploader'],
291 'state': 'processed'}).sort(
292 '_id', DESCENDING).limit(1)
293
294 if cursor.count():
295 return urlgen('mediagoblin.user_pages.media_home',
296 user=self.uploader()['username'],
297 media=unicode(cursor[0]['slug']))
298
299 def uploader(self):
300 return self.db.User.find_one({'_id': self['uploader']})
301
302 def get_fail_exception(self):
303 """
304 Get the exception that's appropriate for this error
305 """
306 if self['fail_error']:
307 return util.import_component(self['fail_error'])
308
309
310 class MediaComment(Document):
311 """
312 A comment on a MediaEntry.
313
314 Structure:
315 - media_entry: The media entry this comment is attached to
316 - author: user who posted this comment
317 - created: when the comment was created
318 - content: plaintext (but markdown'able) version of the comment's content.
319 - content_html: the actual html-rendered version of the comment displayed.
320 Run through Markdown and the HTML cleaner.
321 """
322
323 __collection__ = 'media_comments'
324
325 structure = {
326 'media_entry': ObjectId,
327 'author': ObjectId,
328 'created': datetime.datetime,
329 'content': unicode,
330 'content_html': unicode}
331
332 required_fields = [
333 'media_entry', 'author', 'created', 'content']
334
335 default_values = {
336 'created': datetime.datetime.utcnow}
337
338 def media_entry(self):
339 return self.db.MediaEntry.find_one({'_id': self['media_entry']})
340
341 def author(self):
342 return self.db.User.find_one({'_id': self['author']})
343
344
345 REGISTER_MODELS = [
346 MediaEntry,
347 User,
348 MediaComment]
349
350
351 def register_models(connection):
352 """
353 Register all models in REGISTER_MODELS with this connection.
354 """
355 connection.register(REGISTER_MODELS)
356