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