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