new 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
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 UserConfirmation(models.Model):
184     user = models.ForeignKey(User, models.CASCADE)
185     created_at = models.DateTimeField(auto_now_add=True)
186     key = models.CharField(max_length=128, unique=True)
187
188     def send(self):
189         send_mail(
190             'Potwierdź konto w bibliotece Wolne Lektury',
191             f'https://beta.wolnelektury.pl/ludzie/potwierdz/{self.key}/',
192             settings.CONTACT_EMAIL,
193             [self.user.email]
194         )
195
196     def use(self):
197         user = self.user
198         user.is_active = True
199         user.save()
200         self.delete()
201     
202     @classmethod
203     def request(cls, user):
204         cls.objects.create(
205             user=user,
206             key=generate_token()
207         ).send()
208
209
210
211 class Syncable:
212     @classmethod
213     def sync(cls, user, instance, data):
214         ts = data.get('timestamp')
215         if ts is None:
216             ts = now()
217         else:
218             ts = datetime.fromtimestamp(ts, tz=utc)
219
220         if instance is not None:
221             if ts and ts < instance.reported_timestamp:
222                 return
223
224         if instance is None:
225             if data.get('deleted'):
226                 return
227             instance = cls.create_from_data(user, data)
228             if instance is None:
229                 return
230
231         instance.reported_timestamp = ts
232         for f in cls.syncable_fields:
233             if f in data:
234                 setattr(instance, f, data[f])
235         
236         instance.save()
237         return instance
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         raise NotImplementedError
246
247
248 class Progress(Syncable, models.Model):
249     user = models.ForeignKey(User, models.CASCADE)
250     book = models.ForeignKey('catalogue.Book', models.CASCADE)
251     created_at = models.DateTimeField(auto_now_add=True)
252     updated_at = models.DateTimeField(auto_now=True)
253     reported_timestamp = models.DateTimeField()
254     deleted = models.BooleanField(default=False)
255     last_mode = models.CharField(max_length=64, choices=[
256         ('text', 'text'),
257         ('audio', 'audio'),
258     ])
259     text_percent = models.FloatField(null=True, blank=True)
260     text_anchor = models.CharField(max_length=64, blank=True)
261     audio_percent = models.FloatField(null=True, blank=True)
262     audio_timestamp = models.FloatField(null=True, blank=True)
263     implicit_text_percent = models.FloatField(null=True, blank=True)
264     implicit_text_anchor = models.CharField(max_length=64, blank=True)
265     implicit_audio_percent = models.FloatField(null=True, blank=True)
266     implicit_audio_timestamp = models.FloatField(null=True, blank=True)
267
268     syncable_fields = [
269         'deleted',
270         'last_mode', 'text_anchor', 'audio_timestamp'
271     ]
272     
273     class Meta:
274         unique_together = [('user', 'book')]
275
276     @property
277     def timestamp(self):
278         return self.updated_at.timestamp()
279
280     @classmethod
281     def create_from_data(cls, user, data):
282         return cls.objects.create(
283             user=user,
284             book=data['book']
285         )
286         
287     def save(self, *args, **kwargs):
288         try:
289             audio_l = self.book.get_audio_length()
290         except:
291             audio_l = 60
292         if self.text_anchor:
293             self.text_percent = 33
294             if audio_l:
295                 self.implicit_audio_percent = 40
296                 self.implicit_audio_timestamp = audio_l * .4
297         if self.audio_timestamp:
298             if self.audio_timestamp > audio_l:
299                 self.audio_timestamp = audio_l
300             if audio_l:
301                 self.audio_percent = 100 * self.audio_timestamp / audio_l
302                 self.implicit_text_percent = 60
303                 self.implicit_text_anchor = 'f20'
304         return super().save(*args, **kwargs)
305
306
307 class UserList(Syncable, models.Model):
308     slug = models.SlugField(unique=True)
309     user = models.ForeignKey(User, models.CASCADE)
310     name = models.CharField(max_length=1024)
311     favorites = models.BooleanField(default=False)
312     public = models.BooleanField(default=False)
313     deleted = models.BooleanField(default=False)
314     created_at = models.DateTimeField(auto_now_add=True)
315     updated_at = models.DateTimeField(auto_now=True)
316     reported_timestamp = models.DateTimeField()
317
318     syncable_fields = ['name', 'public', 'deleted']
319     
320     def get_absolute_url(self):
321         return reverse(
322             'tagged_object_list',
323             args=[f'polka/{self.slug}']
324         )
325
326     def __str__(self):
327         return self.name
328
329     @property
330     def url_chunk(self):
331         return f'polka/{self.slug}'
332     
333     @classmethod
334     def create_from_data(cls, user, data):
335         return cls.create(user, data['name'])
336
337     @classmethod
338     def create(cls, user, name):
339         n = now()
340         return cls.objects.create(
341             user=user,
342             name=name,
343             slug=get_random_hash(name),
344             updated_at=n,
345             reported_timestamp=n,
346         )
347
348     @classmethod
349     def get_by_name(cls, user, name, create=False):
350         l = cls.objects.filter(
351             user=user,
352             name=name
353         ).first()
354         if l is None and create:
355             l = cls.create(user, name)
356         return l
357         
358     @classmethod
359     def get_favorites_list(cls, user, create=False):
360         try:
361             return cls.objects.get(
362                 user=user,
363                 favorites=True
364             )
365         except cls.DoesNotExist:
366             if create:
367                 return cls.objects.create(
368                     user=user,
369                     favorites=True,
370                     slug=get_random_hash(name),
371                     updated_at=now()
372                 )
373             else:
374                 return None
375         except cls.MultipleObjectsReturned:
376             # merge?
377             lists = list(cls.objects.filter(user=user, favorites=True))
378             for l in lists[1:]:
379                 t.userlistitem_set.all().update(
380                     list=lists[0]
381                 )
382                 l.delete()
383             return lists[0]
384
385     @classmethod
386     def likes(cls, user, book):
387         ls = cls.get_favorites_list(user)
388         if ls is None:
389             return False
390         return ls.userlistitem_set.filter(deleted=False, book=book).exists()
391
392     def append(self, book):
393         # TODO: check for duplicates?
394         n = now()
395         item = self.userlistitem_set.create(
396             book=book,
397             order=(self.userlistitem_set.aggregate(m=models.Max('order'))['m'] or 0) + 1,
398             updated_at=n,
399             reported_timestamp=n,
400         )
401         book.update_popularity()
402         return item
403
404     def remove(self, book):
405         self.userlistitem_set.filter(book=book).update(
406             deleted=True,
407             updated_at=now()
408         )
409         book.update_popularity()
410
411     @classmethod
412     def like(cls, user, book):
413         ul = cls.get_favorites_list(user, create=True)
414         ul.append(book)
415
416     @classmethod
417     def unlike(cls, user, book):
418         ul = cls.get_favorites_list(user)
419         if ul is not None:
420             ul.remove(book)
421
422     def get_books(self):
423         return [item.book for item in self.userlistitem_set.exclude(deleted=True).exclude(book=None)]
424             
425
426 class UserListItem(Syncable, models.Model):
427     list = models.ForeignKey(UserList, models.CASCADE)
428     uuid = models.UUIDField(unique=True, default=uuid.uuid4, editable=False, blank=True)
429     order = models.IntegerField()
430     deleted = models.BooleanField(default=False)
431     created_at = models.DateTimeField(auto_now_add=True)
432     updated_at = models.DateTimeField(auto_now=True)
433     reported_timestamp = models.DateTimeField()
434
435     book = models.ForeignKey('catalogue.Book', models.SET_NULL, null=True, blank=True)
436     fragment = models.ForeignKey('catalogue.Fragment', models.SET_NULL, null=True, blank=True)
437     quote = models.ForeignKey('bookmarks.Quote', models.SET_NULL, null=True, blank=True)
438     bookmark = models.ForeignKey('bookmarks.Bookmark', models.SET_NULL, null=True, blank=True)
439
440     note = models.TextField(blank=True)
441     
442     syncable_fields = ['order', 'deleted', 'book', 'fragment', 'quote', 'bookmark', 'note']
443
444     @classmethod
445     def create_from_data(cls, user, data):
446         if data.get('favorites'):
447             l = UserList.get_favorites_list(user, create=True)
448         else:
449             l = data['list']
450             try:
451                 assert l.user == user
452             except AssertionError:
453                 return
454         return l.append(book=data['book'])
455
456     @property
457     def favorites(self):
458         return self.list.favorites