Password reset
[wolnelektury.git] / src / social / models.py
1 # This file is part of Wolne Lektury, licensed under GNU Affero GPLv3 or later.
2 # Copyright © Fundacja Wolne Lektury. See NOTICE for more information.
3 #
4 from datetime import datetime
5 import uuid
6 from oauthlib.common import urlencode, generate_token
7 from random import randint
8 from django.db import models
9 from django.conf import settings
10 from django.contrib.auth.models import User
11 from django.core.exceptions import ValidationError
12 from django.core.mail import send_mail
13 from django.urls import reverse
14 from django.utils.timezone import now, utc
15 from catalogue.models import Book
16 from catalogue.utils import get_random_hash
17 from wolnelektury.utils import cached_render, clear_cached_renders
18 from .syncable import Syncable
19
20
21 class BannerGroup(models.Model):
22     name = models.CharField('nazwa', max_length=255, unique=True)
23     created_at = models.DateTimeField('utworzona', auto_now_add=True)
24
25     class Meta:
26         ordering = ('name',)
27         verbose_name = 'grupa bannerów'
28         verbose_name_plural = 'grupy bannerów'
29
30     def __str__(self):
31         return self.name
32
33     def get_absolute_url(self):
34         """This is used for testing."""
35         return "%s?banner_group=%d" % (reverse('main_page'), self.id)
36
37     def get_banner(self):
38         banners = self.cite_set.all()
39         count = banners.count()
40         if not count:
41             return None
42         return banners[randint(0, count-1)]
43
44
45 class Cite(models.Model):
46     book = models.ForeignKey(Book, models.CASCADE, verbose_name='książka', null=True, blank=True)
47     text = models.TextField('tekst', blank=True)
48     small = models.BooleanField(
49         'mały', default=False, help_text='Sprawia, że cytat wyświetla się mniejszym fontem.')
50     vip = models.CharField('VIP', max_length=128, null=True, blank=True)
51     link = models.URLField('odnośnik')
52     video = models.URLField('wideo', blank=True)
53     picture = models.ImageField('ilustracja', blank=True,
54             help_text='Najlepsze wymiary: 975 x 315 z tekstem, 487 x 315 bez tekstu.')
55     picture_alt = models.CharField('alternatywny tekst ilustracji', max_length=255, blank=True)
56     picture_title = models.CharField('tytuł ilustracji', max_length=255, null=True, blank=True)
57     picture_author = models.CharField('autor ilustracji', max_length=255, blank=True, null=True)
58     picture_link = models.URLField('link ilustracji', blank=True, null=True)
59     picture_license = models.CharField('nazwa licencji ilustracji', max_length=255, blank=True, null=True)
60     picture_license_link = models.URLField('adres licencji ilustracji', blank=True, null=True)
61
62     sticky = models.BooleanField('przyklejony', default=False, db_index=True,
63                                  help_text='Przyklejone cytaty mają pierwszeństwo.')
64     background_plain = models.BooleanField('jednobarwne tło', default=False)
65     background_color = models.CharField('kolor tła', max_length=32, blank=True)
66     image = models.ImageField(
67         'obraz tła', upload_to='social/cite', null=True, blank=True,
68         help_text='Najlepsze tło ma wielkość 975 x 315 px i waży poniżej 100kB.')
69     image_title = models.CharField('tytuł obrazu tła', max_length=255, null=True, blank=True)
70     image_author = models.CharField('autor obrazu tła', max_length=255, blank=True, null=True)
71     image_link = models.URLField('link obrazu tła', blank=True, null=True)
72     image_license = models.CharField('nazwa licencji obrazu tła', max_length=255, blank=True, null=True)
73     image_license_link = models.URLField('adres licencji obrazu tła', blank=True, null=True)
74
75     created_at = models.DateTimeField('utworzony', auto_now_add=True)
76     group = models.ForeignKey(BannerGroup, verbose_name='grupa', null=True, blank=True, on_delete=models.SET_NULL)
77
78     class Meta:
79         ordering = ('vip', 'text')
80         verbose_name = 'banner'
81         verbose_name_plural = 'bannery'
82
83     def __str__(self):
84         t = []
85         if self.text:
86             t.append(self.text[:60])
87         if self.book_id:
88             t.append('[ks.]'[:60])
89         t.append(self.link[:60])
90         if self.vip:
91             t.append('vip: ' + self.vip)
92         if self.picture:
93             t.append('[obr.]')
94         if self.video:
95             t.append('[vid.]')
96         return ', '.join(t)
97
98     def get_absolute_url(self):
99         """This is used for testing."""
100         return "%s?banner=%d" % (reverse('main_page'), self.id)
101
102     def has_box(self):
103         return self.video or self.picture
104
105     def has_body(self):
106         return self.vip or self.text or self.book
107
108     def layout(self):
109         pieces = []
110         if self.has_box():
111             pieces.append('box')
112         if self.has_body():
113             pieces.append('text')
114         return '-'.join(pieces)
115
116
117     def save(self, *args, **kwargs):
118         ret = super(Cite, self).save(*args, **kwargs)
119         self.clear_cache()
120         return ret
121
122     @cached_render('social/cite_promo.html')
123     def main_box(self):
124         return {
125             'cite': self,
126             'main': True,
127         }
128
129     def clear_cache(self):
130         clear_cached_renders(self.main_box)
131
132
133 class Carousel(models.Model):
134     placement = models.SlugField('miejsce', choices=[
135         ('main', 'main'),
136         ('main_2022', 'main 2022'),
137     ])
138     priority = models.SmallIntegerField('priorytet', default=0)
139     language = models.CharField('język', max_length=2, blank=True, default='', choices=settings.LANGUAGES)
140
141     class Meta:
142 #        ordering = ('placement', '-priority')
143         verbose_name = 'karuzela'
144         verbose_name_plural = 'karuzele'
145
146     def __str__(self):
147         return self.placement
148
149     @classmethod
150     def get(cls, placement):
151         carousel = cls.objects.filter(placement=placement).order_by('-priority', '?').first()
152         if carousel is None:
153             carousel = cls.objects.create(placement=placement)
154         return carousel
155
156
157 class CarouselItem(models.Model):
158     order = models.PositiveSmallIntegerField('kolejność')
159     carousel = models.ForeignKey(Carousel, models.CASCADE, verbose_name='karuzela')
160     banner = models.ForeignKey(
161         Cite, models.CASCADE, null=True, blank=True, verbose_name='banner')
162     banner_group = models.ForeignKey(
163         BannerGroup, models.CASCADE, null=True, blank=True, verbose_name='grupa bannerów')
164
165     class Meta:
166         ordering = ('order',)
167         verbose_name = 'element karuzeli'
168         verbose_name_plural = 'elementy karuzeli'
169
170     def __str__(self):
171         return str(self.banner or self.banner_group)
172
173     def clean(self):
174         if not self.banner and not self.banner_group:
175             raise ValidationError('Proszę wskazać banner albo grupę bannerów.')
176         elif self.banner and self.banner_group:
177             raise ValidationError('Proszę wskazać banner albo grupę bannerów.')
178
179     def get_banner(self):
180         return self.banner or self.banner_group.get_banner()
181
182
183 class UserProfile(models.Model):
184     user = models.OneToOneField(User, models.CASCADE)
185     notifications = models.BooleanField(default=False)
186
187     @classmethod
188     def get_for(cls, user):
189         obj, created = cls.objects.get_or_create(user=user)
190         return obj
191
192
193 class UserConfirmation(models.Model):
194     user = models.ForeignKey(User, models.CASCADE)
195     created_at = models.DateTimeField(auto_now_add=True)
196     key = models.CharField(max_length=128, unique=True)
197
198     def send(self):
199         send_mail(
200             'Potwierdź konto w bibliotece Wolne Lektury',
201             f'https://beta.wolnelektury.pl/ludzie/potwierdz/{self.key}/',
202             settings.CONTACT_EMAIL,
203             [self.user.email]
204         )
205
206     def use(self):
207         user = self.user
208         user.is_active = True
209         user.save()
210         self.delete()
211     
212     @classmethod
213     def request(cls, user):
214         cls.objects.create(
215             user=user,
216             key=generate_token()
217         ).send()
218
219
220 class Progress(Syncable, models.Model):
221     user = models.ForeignKey(User, models.CASCADE)
222     book = models.ForeignKey('catalogue.Book', models.CASCADE)
223     created_at = models.DateTimeField(auto_now_add=True)
224     updated_at = models.DateTimeField(auto_now=True)
225     reported_timestamp = models.DateTimeField()
226     deleted = models.BooleanField(default=False)
227     last_mode = models.CharField(max_length=64, choices=[
228         ('text', 'text'),
229         ('audio', 'audio'),
230     ])
231     text_percent = models.FloatField(null=True, blank=True)
232     text_anchor = models.CharField(max_length=64, blank=True)
233     audio_percent = models.FloatField(null=True, blank=True)
234     audio_timestamp = models.FloatField(null=True, blank=True)
235     implicit_text_percent = models.FloatField(null=True, blank=True)
236     implicit_text_anchor = models.CharField(max_length=64, blank=True)
237     implicit_audio_percent = models.FloatField(null=True, blank=True)
238     implicit_audio_timestamp = models.FloatField(null=True, blank=True)
239
240     syncable_fields = [
241         'deleted',
242         'last_mode', 'text_anchor', 'audio_timestamp'
243     ]
244     
245     class Meta:
246         unique_together = [('user', 'book')]
247
248     @property
249     def timestamp(self):
250         return self.updated_at.timestamp()
251
252     @classmethod
253     def create_from_data(cls, user, data):
254         return cls.objects.create(
255             user=user,
256             book=data['book'],
257             reported_timestamp=now(),
258         )
259         
260     def save(self, *args, **kwargs):
261         try:
262             audio_l = self.book.get_audio_length()
263         except:
264             audio_l = 60
265         if self.text_anchor:
266             self.text_percent = 33
267             if audio_l:
268                 self.implicit_audio_percent = 40
269                 self.implicit_audio_timestamp = audio_l * .4
270         if self.audio_timestamp:
271             if self.audio_timestamp > audio_l:
272                 self.audio_timestamp = audio_l
273             if audio_l:
274                 self.audio_percent = 100 * self.audio_timestamp / audio_l
275                 self.implicit_text_percent = 60
276                 self.implicit_text_anchor = 'f20'
277         return super().save(*args, **kwargs)
278
279
280 class UserList(Syncable, models.Model):
281     slug = models.SlugField(unique=True)
282     user = models.ForeignKey(User, models.CASCADE)
283     name = models.CharField(max_length=1024)
284     favorites = models.BooleanField(default=False)
285     public = models.BooleanField(default=False)
286     deleted = models.BooleanField(default=False)
287     created_at = models.DateTimeField(auto_now_add=True)
288     updated_at = models.DateTimeField(auto_now=True)
289     reported_timestamp = models.DateTimeField()
290
291     syncable_fields = ['name', 'public', 'deleted']
292     
293     def get_absolute_url(self):
294         return reverse(
295             'tagged_object_list',
296             args=[f'polka/{self.slug}']
297         )
298
299     def __str__(self):
300         return self.name
301
302     @property
303     def url_chunk(self):
304         return f'polka/{self.slug}'
305     
306     @classmethod
307     def create_from_data(cls, user, data):
308         return cls.create(user, data['name'])
309
310     @classmethod
311     def create(cls, user, name):
312         n = now()
313         return cls.objects.create(
314             user=user,
315             name=name,
316             slug=get_random_hash(name),
317             updated_at=n,
318             reported_timestamp=n,
319         )
320
321     @classmethod
322     def get_by_name(cls, user, name, create=False):
323         l = cls.objects.filter(
324             user=user,
325             name=name
326         ).first()
327         if l is None and create:
328             l = cls.create(user, name)
329         return l
330         
331     @classmethod
332     def get_favorites_list(cls, user, create=False):
333         try:
334             return cls.objects.get(
335                 user=user,
336                 favorites=True
337             )
338         except cls.DoesNotExist:
339             n = now()
340             if create:
341                 return cls.objects.create(
342                     user=user,
343                     favorites=True,
344                     slug=get_random_hash('favorites'),
345                     updated_at=n,
346                     reported_timestamp=n,
347                 )
348             else:
349                 return None
350         except cls.MultipleObjectsReturned:
351             # merge?
352             lists = list(cls.objects.filter(user=user, favorites=True))
353             for l in lists[1:]:
354                 t.userlistitem_set.all().update(
355                     list=lists[0]
356                 )
357                 l.delete()
358             return lists[0]
359
360     @classmethod
361     def likes(cls, user, book):
362         ls = cls.get_favorites_list(user)
363         if ls is None:
364             return False
365         return ls.userlistitem_set.filter(deleted=False, book=book).exists()
366
367     def append(self, book):
368         # TODO: check for duplicates?
369         n = now()
370         item = self.userlistitem_set.create(
371             book=book,
372             order=(self.userlistitem_set.aggregate(m=models.Max('order'))['m'] or 0) + 1,
373             updated_at=n,
374             reported_timestamp=n,
375         )
376         book.update_popularity()
377         return item
378
379     def remove(self, book):
380         self.userlistitem_set.filter(book=book).update(
381             deleted=True,
382             updated_at=now()
383         )
384         book.update_popularity()
385
386     @classmethod
387     def like(cls, user, book):
388         ul = cls.get_favorites_list(user, create=True)
389         ul.append(book)
390
391     @classmethod
392     def unlike(cls, user, book):
393         ul = cls.get_favorites_list(user)
394         if ul is not None:
395             ul.remove(book)
396
397     def get_books(self):
398         return [item.book for item in self.userlistitem_set.exclude(deleted=True).exclude(book=None)]
399             
400
401 class UserListItem(Syncable, models.Model):
402     list = models.ForeignKey(UserList, models.CASCADE)
403     uuid = models.UUIDField(unique=True, default=uuid.uuid4, editable=False, blank=True)
404     order = models.IntegerField()
405     deleted = models.BooleanField(default=False)
406     created_at = models.DateTimeField(auto_now_add=True)
407     updated_at = models.DateTimeField(auto_now=True)
408     reported_timestamp = models.DateTimeField()
409
410     book = models.ForeignKey('catalogue.Book', models.SET_NULL, null=True, blank=True)
411     fragment = models.ForeignKey('catalogue.Fragment', models.SET_NULL, null=True, blank=True)
412     quote = models.ForeignKey('bookmarks.Quote', models.SET_NULL, null=True, blank=True)
413     bookmark = models.ForeignKey('bookmarks.Bookmark', models.SET_NULL, null=True, blank=True)
414
415     note = models.TextField(blank=True)
416     
417     syncable_fields = ['order', 'deleted', 'book', 'fragment', 'quote', 'bookmark', 'note']
418
419     @classmethod
420     def create_from_data(cls, user, data):
421         if data.get('favorites'):
422             l = UserList.get_favorites_list(user, create=True)
423         else:
424             l = data['list']
425             try:
426                 assert l.user == user
427             except AssertionError:
428                 return
429         return l.append(book=data['book'])
430
431     @property
432     def favorites(self):
433         return self.list.favorites