Bookmarks 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         )
249         
250     def save(self, *args, **kwargs):
251         try:
252             audio_l = self.book.get_audio_length()
253         except:
254             audio_l = 60
255         if self.text_anchor:
256             self.text_percent = 33
257             if audio_l:
258                 self.implicit_audio_percent = 40
259                 self.implicit_audio_timestamp = audio_l * .4
260         if self.audio_timestamp:
261             if self.audio_timestamp > audio_l:
262                 self.audio_timestamp = audio_l
263             if audio_l:
264                 self.audio_percent = 100 * self.audio_timestamp / audio_l
265                 self.implicit_text_percent = 60
266                 self.implicit_text_anchor = 'f20'
267         return super().save(*args, **kwargs)
268
269
270 class UserList(Syncable, models.Model):
271     slug = models.SlugField(unique=True)
272     user = models.ForeignKey(User, models.CASCADE)
273     name = models.CharField(max_length=1024)
274     favorites = models.BooleanField(default=False)
275     public = models.BooleanField(default=False)
276     deleted = models.BooleanField(default=False)
277     created_at = models.DateTimeField(auto_now_add=True)
278     updated_at = models.DateTimeField(auto_now=True)
279     reported_timestamp = models.DateTimeField()
280
281     syncable_fields = ['name', 'public', 'deleted']
282     
283     def get_absolute_url(self):
284         return reverse(
285             'tagged_object_list',
286             args=[f'polka/{self.slug}']
287         )
288
289     def __str__(self):
290         return self.name
291
292     @property
293     def url_chunk(self):
294         return f'polka/{self.slug}'
295     
296     @classmethod
297     def create_from_data(cls, user, data):
298         return cls.create(user, data['name'])
299
300     @classmethod
301     def create(cls, user, name):
302         n = now()
303         return cls.objects.create(
304             user=user,
305             name=name,
306             slug=get_random_hash(name),
307             updated_at=n,
308             reported_timestamp=n,
309         )
310
311     @classmethod
312     def get_by_name(cls, user, name, create=False):
313         l = cls.objects.filter(
314             user=user,
315             name=name
316         ).first()
317         if l is None and create:
318             l = cls.create(user, name)
319         return l
320         
321     @classmethod
322     def get_favorites_list(cls, user, create=False):
323         try:
324             return cls.objects.get(
325                 user=user,
326                 favorites=True
327             )
328         except cls.DoesNotExist:
329             if create:
330                 return cls.objects.create(
331                     user=user,
332                     favorites=True,
333                     slug=get_random_hash(name),
334                     updated_at=now()
335                 )
336             else:
337                 return None
338         except cls.MultipleObjectsReturned:
339             # merge?
340             lists = list(cls.objects.filter(user=user, favorites=True))
341             for l in lists[1:]:
342                 t.userlistitem_set.all().update(
343                     list=lists[0]
344                 )
345                 l.delete()
346             return lists[0]
347
348     @classmethod
349     def likes(cls, user, book):
350         ls = cls.get_favorites_list(user)
351         if ls is None:
352             return False
353         return ls.userlistitem_set.filter(deleted=False, book=book).exists()
354
355     def append(self, book):
356         # TODO: check for duplicates?
357         n = now()
358         item = self.userlistitem_set.create(
359             book=book,
360             order=(self.userlistitem_set.aggregate(m=models.Max('order'))['m'] or 0) + 1,
361             updated_at=n,
362             reported_timestamp=n,
363         )
364         book.update_popularity()
365         return item
366
367     def remove(self, book):
368         self.userlistitem_set.filter(book=book).update(
369             deleted=True,
370             updated_at=now()
371         )
372         book.update_popularity()
373
374     @classmethod
375     def like(cls, user, book):
376         ul = cls.get_favorites_list(user, create=True)
377         ul.append(book)
378
379     @classmethod
380     def unlike(cls, user, book):
381         ul = cls.get_favorites_list(user)
382         if ul is not None:
383             ul.remove(book)
384
385     def get_books(self):
386         return [item.book for item in self.userlistitem_set.exclude(deleted=True).exclude(book=None)]
387             
388
389 class UserListItem(Syncable, models.Model):
390     list = models.ForeignKey(UserList, models.CASCADE)
391     uuid = models.UUIDField(unique=True, default=uuid.uuid4, editable=False, blank=True)
392     order = models.IntegerField()
393     deleted = models.BooleanField(default=False)
394     created_at = models.DateTimeField(auto_now_add=True)
395     updated_at = models.DateTimeField(auto_now=True)
396     reported_timestamp = models.DateTimeField()
397
398     book = models.ForeignKey('catalogue.Book', models.SET_NULL, null=True, blank=True)
399     fragment = models.ForeignKey('catalogue.Fragment', models.SET_NULL, null=True, blank=True)
400     quote = models.ForeignKey('bookmarks.Quote', models.SET_NULL, null=True, blank=True)
401     bookmark = models.ForeignKey('bookmarks.Bookmark', models.SET_NULL, null=True, blank=True)
402
403     note = models.TextField(blank=True)
404     
405     syncable_fields = ['order', 'deleted', 'book', 'fragment', 'quote', 'bookmark', 'note']
406
407     @classmethod
408     def create_from_data(cls, user, data):
409         if data.get('favorites'):
410             l = UserList.get_favorites_list(user, create=True)
411         else:
412             l = data['list']
413             try:
414                 assert l.user == user
415             except AssertionError:
416                 return
417         return l.append(book=data['book'])
418
419     @property
420     def favorites(self):
421         return self.list.favorites