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