class Addon(models.Model): component = models.ForeignKey(Component, on_delete=models.deletion.CASCADE) name = models.CharField(max_length=100) configuration = JSONField() state = JSONField() project_scope = models.BooleanField(default=False, db_index=True) repo_scope = models.BooleanField(default=False, db_index=True) objects = AddonQuerySet.as_manager() def __str__(self): return '{}: {}'.format(self.addon.verbose, self.component) def configure_events(self, events): for event in events: Event.objects.get_or_create(addon=self, event=event) self.event_set.exclude(event__in=events).delete() @cached_property def addon(self): return ADDONS[self.name](self) def get_absolute_url(self): return reverse('addon-detail', kwargs={ 'project': self.component.project.slug, 'component': self.component.slug, 'pk': self.pk, })
class Addon(models.Model): component = models.ForeignKey(SubProject, on_delete=models.deletion.CASCADE) name = models.CharField(max_length=100) configuration = JSONField() state = JSONField() objects = AddonQuerySet.as_manager() class Meta(object): unique_together = ('component', 'name') def __str__(self): return '{}: {}'.format(self.addon.verbose, self.component) def configure_events(self, events): for event in events: Event.objects.get_or_create(addon=self, event=event) self.event_set.exclude(event__in=events).delete() @cached_property def addon(self): return ADDONS[self.name](self) def get_absolute_url(self): return reverse('addon-detail', kwargs={ 'project': self.component.project.slug, 'subproject': self.component.slug, 'pk': self.pk, })
class Alert(models.Model): component = models.ForeignKey("Component", on_delete=models.deletion.CASCADE) timestamp = models.DateTimeField(auto_now_add=True) updated = models.DateTimeField(auto_now=True) name = models.CharField(max_length=150) dismissed = models.BooleanField(default=False, db_index=True) details = JSONField(default={}) class Meta: unique_together = ("component", "name") verbose_name = "component alert" verbose_name_plural = "component alerts" def __str__(self): return str(self.obj.verbose) def save(self, *args, **kwargs): is_new = not self.id super().save(*args, **kwargs) if is_new: from weblate.trans.models import Change Change.objects.create( action=Change.ACTION_ALERT, component=self.component, alert=self, details={"alert": self.name}, ) @cached_property def obj(self): return ALERTS[self.name](self, **self.details) def render(self, user): return self.obj.render(user)
class Comment(models.Model, UserDisplayMixin): unit = models.ForeignKey("trans.Unit", on_delete=models.deletion.CASCADE) comment = models.TextField() user = models.ForeignKey( settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.deletion.CASCADE, ) timestamp = models.DateTimeField(auto_now_add=True, db_index=True) resolved = models.BooleanField(default=False, db_index=True) userdetails = JSONField() objects = CommentManager.from_queryset(CommentQuerySet)() weblate_unsafe_delete = True class Meta: app_label = "trans" verbose_name = "string comment" verbose_name_plural = "string comments" def __str__(self): return "comment for {0} by {1}".format( self.unit, self.user.username if self.user else "unknown") def report_spam(self): report_spam(self.userdetails["address"], self.userdetails["agent"], self.comment)
class Alert(models.Model): component = models.ForeignKey('Component', on_delete=models.deletion.CASCADE) timestamp = models.DateTimeField(auto_now_add=True) name = models.CharField(max_length=150) details = JSONField(default={}) class Meta(object): unique_together = ('component', 'name') @cached_property def obj(self): return ALERTS[self.name](self, **self.details) def __str__(self): return force_text(self.obj.verbose) def render(self): return self.obj.render() def save(self, *args, **kwargs): is_new = not self.id super(Alert, self).save(*args, **kwargs) if is_new: from weblate.trans.models import Change Change.objects.create( action=Change.ACTION_ALERT, component=self.component, alert=self, details={'alert': self.name}, )
class AuditLog(models.Model): """User audit log storage.""" user = models.ForeignKey( User, on_delete=models.deletion.CASCADE, ) activity = models.CharField( max_length=20, choices=[(a, a) for a in sorted(ACCOUNT_ACTIVITY.keys())], db_index=True, ) params = JSONField() address = models.GenericIPAddressField() user_agent = models.CharField(max_length=200, default='') timestamp = models.DateTimeField(auto_now_add=True, db_index=True) objects = AuditLogManager.from_queryset(AuditLogQuerySet)() class Meta(object): ordering = ['-timestamp'] def get_message(self): return ACCOUNT_ACTIVITY[self.activity].format(**self.params) get_message.short_description = _('Account activity') def should_notify(self): return self.activity in NOTIFY_ACTIVITY def __str__(self): return '{0} for {1} from {2}'.format(self.activity, self.user.username, self.address)
class Invoice(models.Model): CURRENCY_EUR = 0 CURRENCY_BTC = 1 CURRENCY_USD = 2 CURRENCY_CZK = 3 billing = models.ForeignKey(Billing, on_delete=models.deletion.CASCADE) start = models.DateField() end = models.DateField() payment = models.FloatField() currency = models.IntegerField( choices=( (CURRENCY_EUR, 'EUR'), (CURRENCY_BTC, 'mBTC'), (CURRENCY_USD, 'USD'), (CURRENCY_CZK, 'CZK'), ), default=CURRENCY_EUR, ) ref = models.CharField(blank=True, max_length=50) note = models.TextField(blank=True) # Payment detailed information, used for integration # with payment processor payment = JSONField(editable=False, default={}) class Meta(object): ordering = ['billing', '-start'] def __str__(self): return '{0} - {1}: {2}'.format( self.start, self.end, self.billing if self.billing_id else None) @property def filename(self): if self.ref: return '{0}.pdf'.format(self.ref) return None def clean(self): if self.end is None or self.start is None: return if self.end <= self.start: raise ValidationError('Start has be to before end!') if not self.billing_id: return overlapping = Invoice.objects.filter( (Q(start__lte=self.end) & Q(end__gte=self.end)) | (Q(start__lte=self.start) & Q(end__gte=self.start))).filter( billing=self.billing) if self.pk: overlapping = overlapping.exclude(pk=self.pk) if overlapping.exists(): raise ValidationError('Overlapping invoices exist: {0}'.format( ', '.join([str(x) for x in overlapping])))
class AuditLog(models.Model): """User audit log storage.""" user = models.ForeignKey( User, on_delete=models.deletion.CASCADE, ) activity = models.CharField( max_length=20, choices=[(a, a) for a in sorted(ACCOUNT_ACTIVITY.keys())], db_index=True, ) params = JSONField() address = models.GenericIPAddressField(null=True) user_agent = models.CharField(max_length=200, default='') timestamp = models.DateTimeField(auto_now_add=True, db_index=True) objects = AuditLogManager.from_queryset(AuditLogQuerySet)() ordering = ['-timestamp'] def get_params(self): result = {} result.update(self.params) if 'method' in result: result['method'] = ugettext(result['method']) return result def get_message(self): method = self.params.get('method') activity = self.activity if activity in ACCOUNT_ACTIVITY_METHOD.get(method, {}): message = ACCOUNT_ACTIVITY_METHOD[method][activity] else: message = ACCOUNT_ACTIVITY[activity] return message.format(**self.get_params()) get_message.short_description = _('Account activity') def get_extra_message(self): if self.activity in EXTRA_MESSAGES: return EXTRA_MESSAGES[self.activity].format( **self.params ) return None def should_notify(self): return self.activity in NOTIFY_ACTIVITY and not self.user.is_demo def __str__(self): return '{0} for {1} from {2}'.format( self.activity, self.user.username, self.address )
class Addon(models.Model): component = models.ForeignKey(Component, on_delete=models.deletion.CASCADE) name = models.CharField(max_length=100) configuration = JSONField() state = JSONField() project_scope = models.BooleanField(default=False, db_index=True) repo_scope = models.BooleanField(default=False, db_index=True) objects = AddonQuerySet.as_manager() class Meta: verbose_name = "add-on" verbose_name_plural = "add-ons" def __str__(self): return "{}: {}".format(self.addon.verbose, self.component) def get_absolute_url(self): return reverse( "addon-detail", kwargs={ "project": self.component.project.slug, "component": self.component.slug, "pk": self.pk, }, ) def configure_events(self, events): for event in events: Event.objects.get_or_create(addon=self, event=event) self.event_set.exclude(event__in=events).delete() @cached_property def addon(self): return ADDONS[self.name](self) def delete(self, *args, **kwargs): # Delete any addon alerts if self.addon.alert: self.component.alert_set.filter(name=self.addon.alert).delete() super().delete(*args, **kwargs)
class Alert(models.Model): component = models.ForeignKey('Component', on_delete=models.deletion.CASCADE) timestamp = models.DateTimeField(auto_now=True) name = models.CharField(max_length=150) details = JSONField(default={}) class Meta(object): ordering = ['name'] unique_together = ('component', 'name') @cached_property def obj(self): return ALERTS[self.name](self, **self.details) def __str__(self): return force_text(self.obj.verbose) def render(self): return self.obj.render()
class AuditLog(models.Model): """User audit log storage.""" user = models.ForeignKey(User, on_delete=models.deletion.CASCADE) activity = models.CharField( max_length=20, choices=[(a, a) for a in sorted(ACCOUNT_ACTIVITY.keys())], db_index=True, ) params = JSONField() address = models.GenericIPAddressField(null=True) user_agent = models.CharField(max_length=200, default="") timestamp = models.DateTimeField(auto_now_add=True, db_index=True) objects = AuditLogManager.from_queryset(AuditLogQuerySet)() def __str__(self): return f"{self.activity} for {self.user.username} from {self.address}" def save(self, *args, **kwargs): super().save(*args, **kwargs) if self.should_notify(): email = self.user.email transaction.on_commit( lambda: notify_auditlog.delay(self.pk, email)) def get_params(self): from weblate.accounts.templatetags.authnames import get_auth_name result = {} result.update(self.params) if "method" in result: # The gettext is here for legacy entries which contained method name result["method"] = gettext(get_auth_name(result["method"])) return result def get_message(self): method = self.params.get("method") activity = self.activity if activity in ACCOUNT_ACTIVITY_METHOD.get(method, {}): message = ACCOUNT_ACTIVITY_METHOD[method][activity] else: message = ACCOUNT_ACTIVITY[activity] return message.format(**self.get_params()) get_message.short_description = _("Account activity") def get_extra_message(self): if self.activity in EXTRA_MESSAGES: return EXTRA_MESSAGES[self.activity].format(**self.params) return None def should_notify(self): return self.user.is_active and self.activity in NOTIFY_ACTIVITY def check_rate_limit(self, request): """Check whether the activity should be rate limited.""" if self.activity == "failed-auth" and self.user.has_usable_password(): failures = AuditLog.objects.get_after(self.user, "login", "failed-auth") if failures.count() >= settings.AUTH_LOCK_ATTEMPTS: self.user.set_unusable_password() self.user.save(update_fields=["password"]) AuditLog.objects.create(self.user, request, "locked") return True elif self.activity == "reset-request": failures = AuditLog.objects.filter( user=self.user, timestamp__gte=timezone.now() - datetime.timedelta(days=1), activity="reset-request", ) if failures.count() >= settings.AUTH_LOCK_ATTEMPTS: return True return False
class Change(models.Model, UserDisplayMixin): ACTION_UPDATE = 0 ACTION_COMPLETE = 1 ACTION_CHANGE = 2 ACTION_COMMENT = 3 ACTION_SUGGESTION = 4 ACTION_NEW = 5 ACTION_AUTO = 6 ACTION_ACCEPT = 7 ACTION_REVERT = 8 ACTION_UPLOAD = 9 ACTION_DICTIONARY_NEW = 10 ACTION_DICTIONARY_EDIT = 11 ACTION_DICTIONARY_UPLOAD = 12 ACTION_NEW_SOURCE = 13 ACTION_LOCK = 14 ACTION_UNLOCK = 15 ACTION_DUPLICATE_STRING = 16 ACTION_COMMIT = 17 ACTION_PUSH = 18 ACTION_RESET = 19 ACTION_MERGE = 20 ACTION_REBASE = 21 ACTION_FAILED_MERGE = 22 ACTION_FAILED_REBASE = 23 ACTION_PARSE_ERROR = 24 ACTION_REMOVE_TRANSLATION = 25 ACTION_SUGGESTION_DELETE = 26 ACTION_REPLACE = 27 ACTION_FAILED_PUSH = 28 ACTION_SUGGESTION_CLEANUP = 29 ACTION_SOURCE_CHANGE = 30 ACTION_NEW_UNIT = 31 ACTION_MASS_STATE = 32 ACTION_ACCESS_EDIT = 33 ACTION_ADD_USER = 34 ACTION_REMOVE_USER = 35 ACTION_APPROVE = 36 ACTION_MARKED_EDIT = 37 ACTION_REMOVE_COMPONENT = 38 ACTION_REMOVE_PROJECT = 39 ACTION_DUPLICATE_LANGUAGE = 40 ACTION_RENAME_PROJECT = 41 ACTION_RENAME_COMPONENT = 42 ACTION_MOVE_COMPONENT = 43 ACTION_NEW_STRING = 44 ACTION_NEW_CONTRIBUTOR = 45 ACTION_MESSAGE = 46 ACTION_ALERT = 47 ACTION_ADDED_LANGUAGE = 48 ACTION_REQUESTED_LANGUAGE = 49 ACTION_CREATE_PROJECT = 50 ACTION_CREATE_COMPONENT = 51 ACTION_CHOICES = ( (ACTION_UPDATE, ugettext_lazy('Resource update')), (ACTION_COMPLETE, ugettext_lazy('Translation completed')), (ACTION_CHANGE, ugettext_lazy('Translation changed')), (ACTION_NEW, ugettext_lazy('New translation')), (ACTION_COMMENT, ugettext_lazy('Comment added')), (ACTION_SUGGESTION, ugettext_lazy('Suggestion added')), (ACTION_AUTO, ugettext_lazy('Automatic translation')), (ACTION_ACCEPT, ugettext_lazy('Suggestion accepted')), (ACTION_REVERT, ugettext_lazy('Translation reverted')), (ACTION_UPLOAD, ugettext_lazy('Translation uploaded')), (ACTION_DICTIONARY_NEW, ugettext_lazy('Glossary added')), (ACTION_DICTIONARY_EDIT, ugettext_lazy('Glossary updated')), (ACTION_DICTIONARY_UPLOAD, ugettext_lazy('Glossary uploaded')), (ACTION_NEW_SOURCE, ugettext_lazy('New source string')), (ACTION_LOCK, ugettext_lazy('Component locked')), (ACTION_UNLOCK, ugettext_lazy('Component unlocked')), (ACTION_DUPLICATE_STRING, ugettext_lazy('Found duplicated string')), (ACTION_COMMIT, ugettext_lazy('Committed changes')), (ACTION_PUSH, ugettext_lazy('Pushed changes')), (ACTION_RESET, ugettext_lazy('Reset repository')), (ACTION_MERGE, ugettext_lazy('Merged repository')), (ACTION_REBASE, ugettext_lazy('Rebased repository')), (ACTION_FAILED_MERGE, ugettext_lazy('Failed merge on repository')), (ACTION_FAILED_REBASE, ugettext_lazy('Failed rebase on repository')), (ACTION_FAILED_PUSH, ugettext_lazy('Failed push on repository')), (ACTION_PARSE_ERROR, ugettext_lazy('Parse error')), (ACTION_REMOVE_TRANSLATION, ugettext_lazy('Removed translation')), (ACTION_SUGGESTION_DELETE, ugettext_lazy('Suggestion removed')), (ACTION_REPLACE, ugettext_lazy('Search and replace')), (ACTION_SUGGESTION_CLEANUP, ugettext_lazy('Suggestion removed during cleanup')), (ACTION_SOURCE_CHANGE, ugettext_lazy('Source string changed')), (ACTION_NEW_UNIT, ugettext_lazy('New string added')), (ACTION_MASS_STATE, ugettext_lazy('Bulk status change')), (ACTION_ACCESS_EDIT, ugettext_lazy('Changed visibility')), (ACTION_ADD_USER, ugettext_lazy('Added user')), (ACTION_REMOVE_USER, ugettext_lazy('Removed user')), (ACTION_APPROVE, ugettext_lazy('Translation approved')), (ACTION_MARKED_EDIT, ugettext_lazy('Marked for edit')), (ACTION_REMOVE_COMPONENT, ugettext_lazy('Removed component')), (ACTION_REMOVE_PROJECT, ugettext_lazy('Removed project')), (ACTION_DUPLICATE_LANGUAGE, ugettext_lazy('Found duplicated language')), (ACTION_RENAME_PROJECT, ugettext_lazy('Renamed project')), (ACTION_RENAME_COMPONENT, ugettext_lazy('Renamed component')), (ACTION_MOVE_COMPONENT, ugettext_lazy('Moved component')), (ACTION_NEW_STRING, ugettext_lazy('New string to translate')), (ACTION_NEW_CONTRIBUTOR, ugettext_lazy('New contributor')), (ACTION_MESSAGE, ugettext_lazy('New whiteboard message')), (ACTION_ALERT, ugettext_lazy('New alert')), (ACTION_ADDED_LANGUAGE, ugettext_lazy('Added new language')), (ACTION_REQUESTED_LANGUAGE, ugettext_lazy('Requested new language')), (ACTION_CREATE_PROJECT, ugettext_lazy('Created project')), (ACTION_CREATE_COMPONENT, ugettext_lazy('Created component')), ) ACTIONS_REVERTABLE = frozenset(( ACTION_ACCEPT, ACTION_REVERT, ACTION_CHANGE, ACTION_UPLOAD, ACTION_NEW, ACTION_REPLACE, )) ACTIONS_CONTENT = frozenset(( ACTION_CHANGE, ACTION_NEW, ACTION_AUTO, ACTION_ACCEPT, ACTION_REVERT, ACTION_UPLOAD, ACTION_REPLACE, ACTION_MASS_STATE, ACTION_APPROVE, ACTION_MARKED_EDIT, )) ACTIONS_REPOSITORY = frozenset(( ACTION_PUSH, ACTION_RESET, ACTION_MERGE, ACTION_REBASE, ACTION_FAILED_MERGE, ACTION_FAILED_REBASE, ACTION_FAILED_PUSH, ACTION_LOCK, ACTION_UNLOCK, ACTION_DUPLICATE_LANGUAGE, )) ACTIONS_MERGE_FAILURE = frozenset(( ACTION_FAILED_MERGE, ACTION_FAILED_REBASE, ACTION_FAILED_PUSH, )) unit = models.ForeignKey('Unit', null=True, on_delete=models.deletion.CASCADE) project = models.ForeignKey('Project', null=True, on_delete=models.deletion.CASCADE) component = models.ForeignKey('Component', null=True, on_delete=models.deletion.CASCADE) translation = models.ForeignKey('Translation', null=True, on_delete=models.deletion.CASCADE) dictionary = models.ForeignKey('Dictionary', null=True, on_delete=models.deletion.CASCADE) comment = models.ForeignKey('Comment', null=True, on_delete=models.deletion.SET_NULL) suggestion = models.ForeignKey('Suggestion', null=True, on_delete=models.deletion.SET_NULL) whiteboard = models.ForeignKey('WhiteboardMessage', null=True, on_delete=models.deletion.SET_NULL) alert = models.ForeignKey('Alert', null=True, on_delete=models.deletion.SET_NULL) user = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, on_delete=models.deletion.CASCADE) author = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, related_name='author_set', on_delete=models.deletion.CASCADE) timestamp = models.DateTimeField(auto_now_add=True, db_index=True) action = models.IntegerField(choices=ACTION_CHOICES, default=ACTION_CHANGE) target = models.TextField(default='', blank=True) old = models.TextField(default='', blank=True) details = JSONField() objects = ChangeManager.from_queryset(ChangeQuerySet)() class Meta(object): app_label = 'trans' def __init__(self, *args, **kwargs): self.notify_state = {} super(Change, self).__init__(*args, **kwargs) def __str__(self): return _('%(action)s at %(time)s on %(translation)s by %(user)s') % { 'action': self.get_action_display(), 'time': self.timestamp, 'translation': self.translation, 'user': self.get_user_display(False), } def is_merge_failure(self): return self.action in self.ACTIONS_MERGE_FAILURE def get_absolute_url(self): """Return link either to unit or translation.""" if self.unit is not None: return self.unit.get_absolute_url() return self.get_translation_url() def get_translation_url(self): """Return URL for translation.""" if self.translation is not None: return self.translation.get_absolute_url() if self.component is not None: return self.component.get_absolute_url() if self.dictionary is not None: return self.dictionary.get_parent_url() if self.project is not None: return self.project.get_absolute_url() return None def get_translation_display(self): """Return display name for translation.""" if self.translation is not None: return force_text(self.translation) elif self.component is not None: return force_text(self.component) elif self.dictionary is not None: return '{0}/{1}'.format(self.dictionary.project, self.dictionary.language) elif self.project is not None: return force_text(self.project) return None def can_revert(self): return (self.unit is not None and self.target and self.action in self.ACTIONS_REVERTABLE) def show_source(self): """Whether to show content as source change.""" return self.action == self.ACTION_SOURCE_CHANGE def show_content(self): """Whether to show content as translation.""" return self.action in ( self.ACTION_SUGGESTION, self.ACTION_SUGGESTION_DELETE, self.ACTION_SUGGESTION_CLEANUP, self.ACTION_NEW_UNIT, ) def get_details_display(self): if not self.details: return '' if self.action == self.ACTION_ACCESS_EDIT: for number, name in Project.ACCESS_CHOICES: if number == self.details['access_control']: return name return 'Unknonwn {}'.format(self.details['access_control']) elif self.action in (self.ACTION_ADD_USER, self.ACTION_REMOVE_USER): if 'group' in self.details: return '{username} ({group})'.format(**self.details) return self.details['username'] elif self.action in (self.ACTION_ADDED_LANGUAGE, self.ACTION_REQUESTED_LANGUAGE): # noqa: E501 try: return Language.objects.get(code=self.details['language']) except Language.DoesNotExist: return self.details['language'] elif self.action == self.ACTION_ALERT: try: return ALERTS[self.details['alert']].verbose except KeyError: return self.details['alert'] return '' def save(self, *args, **kwargs): from weblate.accounts.tasks import notify_change if self.unit: self.translation = self.unit.translation if self.translation: self.component = self.translation.component if self.component: self.project = self.component.project if self.dictionary: self.project = self.dictionary.project super(Change, self).save(*args, **kwargs) notify_change.delay(self.pk)
class Change(models.Model, UserDisplayMixin): ACTION_UPDATE = 0 ACTION_COMPLETE = 1 ACTION_CHANGE = 2 ACTION_COMMENT = 3 ACTION_SUGGESTION = 4 ACTION_NEW = 5 ACTION_AUTO = 6 ACTION_ACCEPT = 7 ACTION_REVERT = 8 ACTION_UPLOAD = 9 ACTION_NEW_SOURCE = 13 ACTION_LOCK = 14 ACTION_UNLOCK = 15 ACTION_DUPLICATE_STRING = 16 ACTION_COMMIT = 17 ACTION_PUSH = 18 ACTION_RESET = 19 ACTION_MERGE = 20 ACTION_REBASE = 21 ACTION_FAILED_MERGE = 22 ACTION_FAILED_REBASE = 23 ACTION_PARSE_ERROR = 24 ACTION_REMOVE_TRANSLATION = 25 ACTION_SUGGESTION_DELETE = 26 ACTION_REPLACE = 27 ACTION_FAILED_PUSH = 28 ACTION_SUGGESTION_CLEANUP = 29 ACTION_SOURCE_CHANGE = 30 ACTION_NEW_UNIT = 31 ACTION_BULK_EDIT = 32 ACTION_ACCESS_EDIT = 33 ACTION_ADD_USER = 34 ACTION_REMOVE_USER = 35 ACTION_APPROVE = 36 ACTION_MARKED_EDIT = 37 ACTION_REMOVE_COMPONENT = 38 ACTION_REMOVE_PROJECT = 39 ACTION_DUPLICATE_LANGUAGE = 40 ACTION_RENAME_PROJECT = 41 ACTION_RENAME_COMPONENT = 42 ACTION_MOVE_COMPONENT = 43 ACTION_NEW_STRING = 44 ACTION_NEW_CONTRIBUTOR = 45 ACTION_ANNOUNCEMENT = 46 ACTION_ALERT = 47 ACTION_ADDED_LANGUAGE = 48 ACTION_REQUESTED_LANGUAGE = 49 ACTION_CREATE_PROJECT = 50 ACTION_CREATE_COMPONENT = 51 ACTION_INVITE_USER = 52 ACTION_HOOK = 53 ACTION_REPLACE_UPLOAD = 54 ACTION_LICENSE_CHANGE = 55 ACTION_AGREEMENT_CHANGE = 56 ACTION_SCREENSHOT_ADDED = 57 ACTION_SCREENSHOT_UPLOADED = 58 ACTION_STRING_REPO_UPDATE = 59 ACTION_CHOICES = ( # Translators: Name of event in the history (ACTION_UPDATE, gettext_lazy("Resource update")), # Translators: Name of event in the history (ACTION_COMPLETE, gettext_lazy("Translation completed")), # Translators: Name of event in the history (ACTION_CHANGE, gettext_lazy("Translation changed")), # Translators: Name of event in the history (ACTION_NEW, gettext_lazy("New translation")), # Translators: Name of event in the history (ACTION_COMMENT, gettext_lazy("Comment added")), # Translators: Name of event in the history (ACTION_SUGGESTION, gettext_lazy("Suggestion added")), # Translators: Name of event in the history (ACTION_AUTO, gettext_lazy("Automatic translation")), # Translators: Name of event in the history (ACTION_ACCEPT, gettext_lazy("Suggestion accepted")), # Translators: Name of event in the history (ACTION_REVERT, gettext_lazy("Translation reverted")), # Translators: Name of event in the history (ACTION_UPLOAD, gettext_lazy("Translation uploaded")), # Translators: Name of event in the history (ACTION_NEW_SOURCE, gettext_lazy("New source string")), # Translators: Name of event in the history (ACTION_LOCK, gettext_lazy("Component locked")), # Translators: Name of event in the history (ACTION_UNLOCK, gettext_lazy("Component unlocked")), # Translators: Name of event in the history (ACTION_DUPLICATE_STRING, gettext_lazy("Found duplicated string")), # Translators: Name of event in the history (ACTION_COMMIT, gettext_lazy("Committed changes")), # Translators: Name of event in the history (ACTION_PUSH, gettext_lazy("Pushed changes")), # Translators: Name of event in the history (ACTION_RESET, gettext_lazy("Reset repository")), # Translators: Name of event in the history (ACTION_MERGE, gettext_lazy("Merged repository")), # Translators: Name of event in the history (ACTION_REBASE, gettext_lazy("Rebased repository")), # Translators: Name of event in the history (ACTION_FAILED_MERGE, gettext_lazy("Failed merge on repository")), # Translators: Name of event in the history (ACTION_FAILED_REBASE, gettext_lazy("Failed rebase on repository")), # Translators: Name of event in the history (ACTION_FAILED_PUSH, gettext_lazy("Failed push on repository")), # Translators: Name of event in the history (ACTION_PARSE_ERROR, gettext_lazy("Parse error")), # Translators: Name of event in the history (ACTION_REMOVE_TRANSLATION, gettext_lazy("Removed translation")), # Translators: Name of event in the history (ACTION_SUGGESTION_DELETE, gettext_lazy("Suggestion removed")), # Translators: Name of event in the history (ACTION_REPLACE, gettext_lazy("Search and replace")), # Translators: Name of event in the history (ACTION_SUGGESTION_CLEANUP, gettext_lazy("Suggestion removed during cleanup")), # Translators: Name of event in the history (ACTION_SOURCE_CHANGE, gettext_lazy("Source string changed")), # Translators: Name of event in the history (ACTION_NEW_UNIT, gettext_lazy("New string added")), # Translators: Name of event in the history (ACTION_BULK_EDIT, gettext_lazy("Bulk status change")), # Translators: Name of event in the history (ACTION_ACCESS_EDIT, gettext_lazy("Changed visibility")), # Translators: Name of event in the history (ACTION_ADD_USER, gettext_lazy("Added user")), # Translators: Name of event in the history (ACTION_REMOVE_USER, gettext_lazy("Removed user")), # Translators: Name of event in the history (ACTION_APPROVE, gettext_lazy("Translation approved")), # Translators: Name of event in the history (ACTION_MARKED_EDIT, gettext_lazy("Marked for edit")), # Translators: Name of event in the history (ACTION_REMOVE_COMPONENT, gettext_lazy("Removed component")), # Translators: Name of event in the history (ACTION_REMOVE_PROJECT, gettext_lazy("Removed project")), # Translators: Name of event in the history (ACTION_DUPLICATE_LANGUAGE, gettext_lazy("Found duplicated language")), # Translators: Name of event in the history (ACTION_RENAME_PROJECT, gettext_lazy("Renamed project")), # Translators: Name of event in the history (ACTION_RENAME_COMPONENT, gettext_lazy("Renamed component")), # Translators: Name of event in the history (ACTION_MOVE_COMPONENT, gettext_lazy("Moved component")), # Using pgettext to differentiate from the plural # Translators: Name of event in the history ( ACTION_NEW_STRING, pgettext_lazy("Name of event in the history", "New string to translate"), ), # Translators: Name of event in the history (ACTION_NEW_CONTRIBUTOR, gettext_lazy("New contributor")), # Translators: Name of event in the history (ACTION_ANNOUNCEMENT, gettext_lazy("New announcement")), # Translators: Name of event in the history (ACTION_ALERT, gettext_lazy("New alert")), # Translators: Name of event in the history (ACTION_ADDED_LANGUAGE, gettext_lazy("Added new language")), # Translators: Name of event in the history (ACTION_REQUESTED_LANGUAGE, gettext_lazy("Requested new language")), # Translators: Name of event in the history (ACTION_CREATE_PROJECT, gettext_lazy("Created project")), # Translators: Name of event in the history (ACTION_CREATE_COMPONENT, gettext_lazy("Created component")), # Translators: Name of event in the history (ACTION_INVITE_USER, gettext_lazy("Invited user")), # Translators: Name of event in the history (ACTION_HOOK, gettext_lazy("Received repository notification")), # Translators: Name of event in the history (ACTION_REPLACE_UPLOAD, gettext_lazy("Replaced file by upload")), # Translators: Name of event in the history (ACTION_LICENSE_CHANGE, gettext_lazy("License changed")), # Translators: Name of event in the history (ACTION_AGREEMENT_CHANGE, gettext_lazy("Contributor agreement changed") ), # Translators: Name of event in the history (ACTION_SCREENSHOT_ADDED, gettext_lazy("Screnshot added")), # Translators: Name of event in the history (ACTION_SCREENSHOT_UPLOADED, gettext_lazy("Screnshot uploaded")), # Translators: Name of event in the history (ACTION_STRING_REPO_UPDATE, gettext_lazy("String updated in the repository")), ) ACTIONS_DICT = dict(ACTION_CHOICES) ACTION_STRINGS = { name.lower().replace(" ", "-"): value for value, name in ACTION_CHOICES } ACTION_NAMES = {str(name): value for value, name in ACTION_CHOICES} # Actions which can be reverted ACTIONS_REVERTABLE = { ACTION_ACCEPT, ACTION_REVERT, ACTION_CHANGE, ACTION_UPLOAD, ACTION_NEW, ACTION_REPLACE, ACTION_AUTO, ACTION_APPROVE, ACTION_MARKED_EDIT, ACTION_STRING_REPO_UPDATE, } # Content changes considered when looking for last author ACTIONS_CONTENT = { ACTION_CHANGE, ACTION_NEW, ACTION_AUTO, ACTION_ACCEPT, ACTION_REVERT, ACTION_UPLOAD, ACTION_REPLACE, ACTION_BULK_EDIT, ACTION_APPROVE, ACTION_MARKED_EDIT, } # Actions shown on the repository management page ACTIONS_REPOSITORY = { ACTION_COMMIT, ACTION_PUSH, ACTION_RESET, ACTION_MERGE, ACTION_REBASE, ACTION_FAILED_MERGE, ACTION_FAILED_REBASE, ACTION_FAILED_PUSH, ACTION_LOCK, ACTION_UNLOCK, ACTION_DUPLICATE_LANGUAGE, } # Actions where target is rendered as translation string ACTIONS_SHOW_CONTENT = { ACTION_SUGGESTION, ACTION_SUGGESTION_DELETE, ACTION_SUGGESTION_CLEANUP, ACTION_BULK_EDIT, ACTION_NEW_UNIT, ACTION_STRING_REPO_UPDATE, } # Actions indicating a repository merge failure ACTIONS_MERGE_FAILURE = { ACTION_FAILED_MERGE, ACTION_FAILED_REBASE, ACTION_FAILED_PUSH, } PLURAL_ACTIONS = { ACTION_NEW_STRING: ngettext_lazy("New string to translate", "New strings to translate"), } AUTO_ACTIONS = { # Translators: Name of event in the history ACTION_LOCK: gettext_lazy( "The component was automatically locked because of an alert."), # Translators: Name of event in the history ACTION_UNLOCK: gettext_lazy("Fixing an alert automatically unlocked the component."), } unit = models.ForeignKey("Unit", null=True, on_delete=models.deletion.CASCADE) language = models.ForeignKey("lang.Language", null=True, on_delete=models.deletion.CASCADE) project = models.ForeignKey("Project", null=True, on_delete=models.deletion.CASCADE) component = models.ForeignKey("Component", null=True, on_delete=models.deletion.CASCADE) translation = models.ForeignKey("Translation", null=True, on_delete=models.deletion.CASCADE) comment = models.ForeignKey("Comment", null=True, on_delete=models.deletion.SET_NULL) suggestion = models.ForeignKey("Suggestion", null=True, on_delete=models.deletion.SET_NULL) announcement = models.ForeignKey("Announcement", null=True, on_delete=models.deletion.SET_NULL) screenshot = models.ForeignKey("screenshots.Screenshot", null=True, on_delete=models.deletion.SET_NULL) alert = models.ForeignKey("Alert", null=True, on_delete=models.deletion.SET_NULL) user = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, on_delete=models.deletion.CASCADE) author = models.ForeignKey( settings.AUTH_USER_MODEL, null=True, related_name="author_set", on_delete=models.deletion.CASCADE, ) timestamp = models.DateTimeField(auto_now_add=True, db_index=True) action = models.IntegerField(choices=ACTION_CHOICES, default=ACTION_CHANGE, db_index=True) target = models.TextField(default="", blank=True) old = models.TextField(default="", blank=True) details = JSONField() objects = ChangeManager.from_queryset(ChangeQuerySet)() class Meta: app_label = "trans" index_together = [ ("translation", "action", "timestamp"), ] verbose_name = "history event" verbose_name_plural = "history events" def __str__(self): return _("%(action)s at %(time)s on %(translation)s by %(user)s") % { "action": self.get_action_display(), "time": self.timestamp, "translation": self.translation, "user": self.get_user_display(False), } def save(self, *args, **kwargs): from weblate.accounts.tasks import notify_change self.fixup_refereces() super().save(*args, **kwargs) transaction.on_commit(lambda: notify_change.delay(self.pk)) def get_absolute_url(self): """Return link either to unit or translation.""" if self.unit is not None: return self.unit.get_absolute_url() if self.screenshot is not None: return self.screenshot.get_absolute_url() if self.translation is not None: if self.action == self.ACTION_NEW_STRING: return self.translation.get_translate_url( ) + "?q=is:untranslated" return self.translation.get_absolute_url() if self.component is not None: return self.component.get_absolute_url() if self.project is not None: return self.project.get_absolute_url() return None def __init__(self, *args, **kwargs): self.notify_state = {} for attr in ("user", "author"): user = kwargs.get(attr) if user is not None and hasattr(user, "get_token_user"): # ProjectToken / ProjectUser integration kwargs[attr] = user.get_token_user() super().__init__(*args, **kwargs) if not self.pk: self.fixup_refereces() def fixup_refereces(self): """Updates refereces based to least specific one.""" if self.unit: self.translation = self.unit.translation if self.screenshot: self.translation = self.screenshot.translation if self.translation: self.component = self.translation.component self.language = self.translation.language if self.component: self.project = self.component.project @property def plural_count(self): return self.details.get("count", 1) @property def auto_status(self): return self.details.get("auto", False) def get_action_display(self): if self.action in self.PLURAL_ACTIONS: return self.PLURAL_ACTIONS[self.action] % self.plural_count return str(self.ACTIONS_DICT.get(self.action, self.action)) def get_state_display(self): state = self.details.get("state") if not state: return "" return STATE_LOOKUP[state] def is_merge_failure(self): return self.action in self.ACTIONS_MERGE_FAILURE def can_revert(self): return (self.unit is not None and self.old and self.action in self.ACTIONS_REVERTABLE) def show_source(self): """Whether to show content as source change.""" return self.action == self.ACTION_SOURCE_CHANGE def show_content(self): """Whether to show content as translation.""" return (self.action in self.ACTIONS_SHOW_CONTENT or self.action in self.ACTIONS_REVERTABLE) def get_details_display(self): # noqa: C901 from weblate.utils.markdown import render_markdown details = self.details if self.action == self.ACTION_NEW_STRING: result = ngettext( "%d new string to translate appeared in the translation.", "%d new strings to translate appeared to the translation.", self.plural_count, ) try: return result % self.plural_count except TypeError: # The string does not contain %d return result if self.action in (self.ACTION_ANNOUNCEMENT, self.ACTION_AGREEMENT_CHANGE): return render_markdown(self.target) if self.action in self.AUTO_ACTIONS and self.auto_status: return str(self.AUTO_ACTIONS[self.action]) if self.action == self.ACTION_UPDATE: reason = details.get("reason", "content changed") filename = "<code>{}</code>".format( escape( details.get( "filename", self.translation.filename if self.translation else "", ))) if reason == "content changed": return mark_safe(_('The "%s" file was changed.') % filename) if reason == "check forced": return mark_safe( _('Parsing of the "%s" file was enforced.') % filename) if reason == "new file": return mark_safe(_("File %s was added.") % filename) raise ValueError(f"Unknown reason: {reason}") if self.action == self.ACTION_LICENSE_CHANGE: not_available = pgettext("License information not available", "N/A") return _( 'The license of the "%(component)s" component was changed ' "from %(old)s to %(target)s.") % { "component": self.component, "old": self.old or not_available, "target": self.target or not_available, } # Following rendering relies on details present if not details: return "" user_actions = { self.ACTION_ADD_USER, self.ACTION_INVITE_USER, self.ACTION_REMOVE_USER, } if self.action == self.ACTION_ACCESS_EDIT: for number, name in Project.ACCESS_CHOICES: if number == details["access_control"]: return name return "Unknonwn {}".format(details["access_control"]) if self.action in user_actions: if "group" in details: return "{username} ({group})".format(**details) return details["username"] if self.action in ( self.ACTION_ADDED_LANGUAGE, self.ACTION_REQUESTED_LANGUAGE, ): # noqa: E501 try: return Language.objects.get(code=details["language"]) except Language.DoesNotExist: return details["language"] if self.action == self.ACTION_ALERT: try: return ALERTS[details["alert"]].verbose except KeyError: return details["alert"] if self.action == self.ACTION_PARSE_ERROR: return "{filename}: {error_message}".format(**details) if self.action == self.ACTION_HOOK: return "{service_long_name}: {repo_url}, {branch}".format( **details) if self.action == self.ACTION_COMMENT and "comment" in details: return render_markdown(details["comment"]) return "" def get_distance(self): try: return damerau_levenshtein_distance(self.old, self.target) except MemoryError: # Too long strings return abs(len(self.old) - len(self.target)) def get_source(self): return self.details.get("source", self.unit.source)
class Billing(models.Model): STATE_ACTIVE = 0 STATE_TRIAL = 1 STATE_EXPIRED = 2 EXPIRING_STATES = (STATE_TRIAL, ) plan = models.ForeignKey( Plan, on_delete=models.deletion.CASCADE, verbose_name=_('Billing plan'), ) projects = models.ManyToManyField( Project, blank=True, verbose_name=_('Billed projects'), ) owners = models.ManyToManyField( User, blank=True, verbose_name=_('Billing owners'), ) state = models.IntegerField( choices=( (STATE_ACTIVE, _('Active')), (STATE_TRIAL, _('Trial')), (STATE_EXPIRED, _('Expired')), ), default=STATE_ACTIVE, verbose_name=_('Billing state'), ) expiry = models.DateTimeField( blank=True, null=True, default=None, verbose_name=_('Trial expiry date'), ) paid = models.BooleanField( default=False, verbose_name=_('Paid'), editable=False, ) # Translators: Whether the package is inside actual (hard) limits in_limits = models.BooleanField( default=True, verbose_name=_('In limits'), editable=False, ) # Payment detailed information, used for integration # with payment processor payment = JSONField(editable=False, default={}) objects = BillingManager.from_queryset(BillingQuerySet)() def __str__(self): projects = self.projects.all() owners = self.owners.all() if projects: base = ', '.join([str(x) for x in projects]) elif owners: base = ', '.join([x.get_author_name(False) for x in owners]) else: base = 'Unassigned' return '{0} ({1})'.format(base, self.plan) def count_changes(self, interval): return Change.objects.filter( component__project__in=self.projects.all(), timestamp__gt=timezone.now() - interval, ).count() def count_changes_1m(self): return self.count_changes(timedelta(days=31)) count_changes_1m.short_description = _('Changes in last month') def count_changes_1q(self): return self.count_changes(timedelta(days=93)) count_changes_1q.short_description = _('Changes in last quarter') def count_changes_1y(self): return self.count_changes(timedelta(days=365)) count_changes_1y.short_description = _('Changes in last year') def count_repositories(self): return Component.objects.filter( project__in=self.projects.all(), ).exclude( repo__startswith='weblate:/').count() def display_repositories(self): return '{0} / {1}'.format(self.count_repositories(), self.plan.display_limit_repositories) display_repositories.short_description = _('VCS repositories') def count_projects(self): return self.projects.count() def display_projects(self): return '{0} / {1}'.format(self.count_projects(), self.plan.display_limit_projects) display_projects.short_description = _('Projects') def count_strings(self): return sum((p.stats.source_strings for p in self.projects.all())) def display_strings(self): return '{0} / {1}'.format(self.count_strings(), self.plan.display_limit_strings) display_strings.short_description = _('Source strings') def count_words(self): return sum((p.stats.source_words for p in self.projects.all())) def display_words(self): return '{0}'.format(self.count_words(), ) display_words.short_description = _('Source words') def count_languages(self): return Language.objects.filter( translation__component__project__in=self.projects.all()).distinct( ).count() def display_languages(self): return '{0} / {1}'.format(self.count_languages(), self.plan.display_limit_languages) display_languages.short_description = _('Languages') def check_in_limits(self, plan=None): if plan is None: plan = self.plan return ((plan.limit_repositories == 0 or self.count_repositories() <= plan.limit_repositories) and (plan.limit_projects == 0 or self.count_projects() <= plan.limit_projects) and (plan.limit_strings == 0 or self.count_strings() <= plan.limit_strings) and (plan.limit_languages == 0 or self.count_languages() <= plan.limit_languages)) def check_expiry(self): return (self.state in Billing.EXPIRING_STATES and self.expiry and self.expiry < timezone.now()) def unit_count(self): return Unit.objects.filter( translation__component__project__in=self.projects.all()).count() unit_count.short_description = _('Number of strings') def last_invoice(self): try: invoice = self.invoice_set.order_by('-start')[0] return '{0} - {1}'.format(invoice.start, invoice.end) except IndexError: return _('N/A') last_invoice.short_description = _('Last invoice') def in_display_limits(self, plan=None): if plan is None: plan = self.plan return ((plan.display_limit_repositories == 0 or self.count_repositories() <= plan.display_limit_repositories) and (plan.display_limit_projects == 0 or self.count_projects() <= plan.display_limit_projects) and (plan.display_limit_strings == 0 or self.count_strings() <= plan.display_limit_strings) and (plan.display_limit_languages == 0 or self.count_languages() <= plan.display_limit_languages)) in_display_limits.boolean = True # Translators: Whether the package is inside displayed (soft) limits in_display_limits.short_description = _('In display limits') def check_payment_status(self): """Check current payment status. Compared to paid attribute, this does not include grace period. """ return (self.plan.price == 0 or self.invoice_set.filter(end__gte=timezone.now()).exists() or self.state == Billing.STATE_TRIAL) def check_limits(self, grace=30, save=True): due_date = timezone.now() - timedelta(days=grace) in_limits = self.check_in_limits() paid = (self.plan.price == 0 or self.invoice_set.filter(end__gt=due_date).exists() or self.state == Billing.STATE_TRIAL) modified = False if self.check_expiry(): self.state = Billing.STATE_EXPIRED self.expiry = None modified = True if self.state not in Billing.EXPIRING_STATES and self.expiry: self.expiry = None modified = True if self.in_limits != in_limits or self.paid != paid: self.in_limits = in_limits self.paid = paid modified = True if save and modified: self.save(skip_limits=True) def save(self, *args, **kwargs): if not kwargs.pop('skip_limits', False) and self.pk: self.check_limits(save=False) super(Billing, self).save(*args, **kwargs) def is_active(self): return self.state in (Billing.STATE_ACTIVE, Billing.STATE_TRIAL)
class Suggestion(models.Model, UserDisplayMixin): unit = models.ForeignKey("trans.Unit", on_delete=models.deletion.CASCADE) target = models.TextField() user = models.ForeignKey( settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.deletion.CASCADE, ) userdetails = JSONField() timestamp = models.DateTimeField(auto_now_add=True) votes = models.ManyToManyField(settings.AUTH_USER_MODEL, through='Vote', related_name='user_votes') objects = SuggestionManager.from_queryset(SuggestionQuerySet)() class Meta: app_label = 'trans' def __str__(self): return 'suggestion for {0} by {1}'.format( self.unit, self.user.username if self.user else 'unknown') @transaction.atomic def accept(self, translation, request, permission='suggestion.accept'): if not request.user.has_perm(permission, self.unit): messages.error(request, _('Failed to accept suggestion!')) return # Skip if there is no change if self.unit.target != self.target or self.unit.state < STATE_TRANSLATED: self.unit.target = self.target self.unit.state = STATE_TRANSLATED self.unit.save_backend(request.user, author=self.user, change_action=Change.ACTION_ACCEPT) # Delete the suggestion self.delete() def delete_log(self, user, change=Change.ACTION_SUGGESTION_DELETE, is_spam=False): """Delete with logging change.""" if is_spam and self.userdetails: report_spam(self.userdetails['address'], self.userdetails['agent'], self.target) Change.objects.create(unit=self.unit, action=change, user=user, target=self.target, author=user) self.delete() def get_num_votes(self): """Return number of votes.""" return self.vote_set.aggregate(Sum('value'))['value__sum'] or 0 def add_vote(self, translation, request, value): """Add (or updates) vote for a suggestion.""" if not request.user.is_authenticated: return vote, created = Vote.objects.get_or_create(suggestion=self, user=request.user, defaults={'value': value}) if not created or vote.value != value: vote.value = value vote.save() # Automatic accepting required_votes = translation.component.suggestion_autoaccept if required_votes and self.get_num_votes() >= required_votes: self.accept(translation, request, 'suggestion.vote') def get_checks(self): # Build fake unit to run checks fake_unit = copy(self.unit) fake_unit.target = self.target fake_unit.state = STATE_TRANSLATED source = fake_unit.get_source_plurals() target = fake_unit.get_target_plurals() result = [] for check, check_obj in CHECKS.target.items(): if check_obj.check_target(source, target, fake_unit): result.append(Check(unit=fake_unit, ignore=False, check=check)) return result
class Suggestion(models.Model, UserDisplayMixin): unit = models.ForeignKey("trans.Unit", on_delete=models.deletion.CASCADE) target = models.TextField() user = models.ForeignKey( settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.deletion.CASCADE, ) userdetails = JSONField() timestamp = models.DateTimeField(auto_now_add=True) votes = models.ManyToManyField(settings.AUTH_USER_MODEL, through="Vote", related_name="user_votes") objects = SuggestionManager.from_queryset(SuggestionQuerySet)() weblate_unsafe_delete = True class Meta: app_label = "trans" verbose_name = "string suggestion" verbose_name_plural = "string suggestions" def __str__(self): return "suggestion for {} by {}".format( self.unit, self.user.username if self.user else "unknown") @transaction.atomic def accept(self, request, permission="suggestion.accept"): if not request.user.has_perm(permission, self.unit): messages.error(request, _("Failed to accept suggestion!")) return # Skip if there is no change if self.unit.target != self.target or self.unit.state < STATE_TRANSLATED: if self.user and not self.user.is_anonymous: author = self.user else: author = request.user self.unit.translate( request.user, split_plural(self.target), STATE_TRANSLATED, author=author, change_action=Change.ACTION_ACCEPT, ) # Delete the suggestion self.delete() def delete_log(self, user, change=Change.ACTION_SUGGESTION_DELETE, is_spam=False): """Delete with logging change.""" if is_spam and self.userdetails: report_spam(self.userdetails["address"], self.userdetails["agent"], self.target) Change.objects.create(unit=self.unit, action=change, user=user, target=self.target, author=user) self.delete() def get_num_votes(self): """Return number of votes.""" return self.vote_set.aggregate(Sum("value"))["value__sum"] or 0 def add_vote(self, request, value): """Add (or updates) vote for a suggestion.""" if request is None or not request.user.is_authenticated: return vote, created = Vote.objects.get_or_create(suggestion=self, user=request.user, defaults={"value": value}) if not created or vote.value != value: vote.value = value vote.save() # Automatic accepting required_votes = self.unit.translation.component.suggestion_autoaccept if required_votes and self.get_num_votes() >= required_votes: self.accept(request, "suggestion.vote") def get_checks(self): # Build fake unit to run checks fake_unit = copy(self.unit) fake_unit.target = self.target fake_unit.state = STATE_TRANSLATED source = fake_unit.get_source_plurals() target = fake_unit.get_target_plurals() result = [] for check, check_obj in CHECKS.target.items(): if check_obj.check_target(source, target, fake_unit): result.append( Check(unit=fake_unit, dismissed=False, check=check)) return result
class Invoice(models.Model): CURRENCY_EUR = 0 CURRENCY_BTC = 1 CURRENCY_USD = 2 CURRENCY_CZK = 3 billing = models.ForeignKey(Billing, on_delete=models.deletion.CASCADE) start = models.DateField() end = models.DateField() amount = models.FloatField() currency = models.IntegerField( choices=( (CURRENCY_EUR, "EUR"), (CURRENCY_BTC, "mBTC"), (CURRENCY_USD, "USD"), (CURRENCY_CZK, "CZK"), ), default=CURRENCY_EUR, ) ref = models.CharField(blank=True, max_length=50) note = models.TextField(blank=True) # Payment detailed information, used for integration # with payment processor payment = JSONField(editable=False, default={}) objects = InvoiceQuerySet.as_manager() def __str__(self): return "{0} - {1}: {2}".format( self.start, self.end, self.billing if self.billing_id else None) @cached_property def filename(self): if self.ref: return "{0}.pdf".format(self.ref) return None @cached_property def full_filename(self): return os.path.join(settings.INVOICE_PATH, self.filename) @cached_property def filename_valid(self): return os.path.exists(self.full_filename) def clean(self): if self.end is None or self.start is None: return if self.end <= self.start: raise ValidationError("Start has be to before end!") if not self.billing_id: return overlapping = Invoice.objects.filter( (Q(start__lte=self.end) & Q(end__gte=self.end)) | (Q(start__lte=self.start) & Q(end__gte=self.start))).filter( billing=self.billing) if self.pk: overlapping = overlapping.exclude(pk=self.pk) if overlapping.exists(): raise ValidationError("Overlapping invoices exist: {0}".format( ", ".join(str(x) for x in overlapping)))
class Billing(models.Model): STATE_ACTIVE = 0 STATE_TRIAL = 1 STATE_EXPIRED = 2 STATE_TERMINATED = 3 EXPIRING_STATES = (STATE_TRIAL, ) plan = models.ForeignKey(Plan, on_delete=models.deletion.CASCADE, verbose_name=_("Billing plan")) projects = models.ManyToManyField(Project, blank=True, verbose_name=_("Billed projects")) owners = models.ManyToManyField(User, blank=True, verbose_name=_("Billing owners")) state = models.IntegerField( choices=( (STATE_ACTIVE, _("Active")), (STATE_TRIAL, _("Trial")), (STATE_EXPIRED, _("Expired")), (STATE_TERMINATED, _("Terminated")), ), default=STATE_ACTIVE, verbose_name=_("Billing state"), ) expiry = models.DateTimeField( blank=True, null=True, default=None, verbose_name=_("Trial expiry date"), help_text= "After expiry removal with 15 days grace period is scheduled.", ) removal = models.DateTimeField( blank=True, null=True, default=None, verbose_name=_("Scheduled removal"), help_text="This is automatically set after trial expiry.", ) paid = models.BooleanField(default=True, verbose_name=_("Paid"), editable=False) # Translators: Whether the package is inside actual (hard) limits in_limits = models.BooleanField(default=True, verbose_name=_("In limits"), editable=False) grace_period = models.IntegerField( default=0, verbose_name=_("Grace period for payments")) # Payment detailed information, used for integration # with payment processor payment = JSONField(editable=False, default={}) objects = BillingManager.from_queryset(BillingQuerySet)() def __str__(self): projects = self.projects_display owners = self.owners.order() if projects: base = projects elif owners: base = ", ".join(x.get_author_name(False) for x in owners) else: base = "Unassigned" return "{0} ({1})".format(base, self.plan) def save( self, force_insert=False, force_update=False, using=None, update_fields=None, skip_limits=False, ): if not skip_limits and self.pk: self.check_limits(save=False) super().save( force_insert=force_insert, force_update=force_update, using=using, update_fields=update_fields, ) def get_absolute_url(self): return "{}#billing-{}".format(reverse("billing"), self.pk) @cached_property def all_projects(self): return prefetch_stats(self.projects.order()) @cached_property def projects_display(self): return ", ".join(str(x) for x in self.all_projects) @property def is_trial(self): return self.state == Billing.STATE_TRIAL @cached_property def can_be_paid(self): if self.state in (Billing.STATE_ACTIVE, Billing.STATE_TRIAL): return True return self.count_projects > 0 @cached_property def monthly_changes(self): return sum(project.stats.monthly_changes for project in self.all_projects) monthly_changes.short_description = _("Changes in last month") @cached_property def total_changes(self): return sum(project.stats.total_changes for project in self.all_projects) total_changes.short_description = _("Number of changes") @cached_property def count_projects(self): return len(self.all_projects) def display_projects(self): return "{0} / {1}".format(self.count_projects, self.plan.display_limit_projects) display_projects.short_description = _("Projects") @cached_property def count_strings(self): return sum(p.stats.source_strings for p in self.all_projects) def display_strings(self): return "{0} / {1}".format(self.count_strings, self.plan.display_limit_strings) display_strings.short_description = _("Source strings") @cached_property def count_words(self): return sum(p.stats.source_words for p in self.all_projects) def display_words(self): return "{0}".format(self.count_words) display_words.short_description = _("Source words") @cached_property def count_languages(self): if not self.all_projects: return 0 return max(p.stats.languages for p in self.all_projects) def display_languages(self): return "{0} / {1}".format(self.count_languages, self.plan.display_limit_languages) display_languages.short_description = _("Languages") def flush_cache(self): keys = list(self.__dict__.keys()) for key in keys: if key.startswith("count_"): del self.__dict__[key] def check_in_limits(self, plan=None): if plan is None: plan = self.plan return ((plan.limit_projects == 0 or self.count_projects <= plan.limit_projects) and (plan.limit_strings == 0 or self.count_strings <= plan.limit_strings) and (plan.limit_languages == 0 or self.count_languages <= plan.limit_languages)) def check_expiry(self): return (self.state in Billing.EXPIRING_STATES and self.expiry and self.expiry < timezone.now()) def unit_count(self): return sum(p.stats.all for p in self.all_projects) unit_count.short_description = _("Number of strings") def last_invoice(self): try: invoice = self.invoice_set.order_by("-start")[0] return "{0} - {1}".format(invoice.start, invoice.end) except IndexError: return _("N/A") last_invoice.short_description = _("Last invoice") def in_display_limits(self, plan=None): if plan is None: plan = self.plan return ((plan.display_limit_projects == 0 or self.count_projects <= plan.display_limit_projects) and (plan.display_limit_strings == 0 or self.count_strings <= plan.display_limit_strings) and (plan.display_limit_languages == 0 or self.count_languages <= plan.display_limit_languages)) in_display_limits.boolean = True # Translators: Whether the package is inside displayed (soft) limits in_display_limits.short_description = _("In display limits") def check_payment_status(self, grace=None): """Check current payment status. Compared to paid attribute, this does not include grace period. """ end = timezone.now() - timedelta(days=grace or self.grace_period) return (self.plan.is_free or self.invoice_set.filter(end__gte=end).exists() or self.state == Billing.STATE_TRIAL) def check_limits(self, grace=30, save=True): self.flush_cache() in_limits = self.check_in_limits() paid = self.check_payment_status(grace) modified = False if self.check_expiry(): self.state = Billing.STATE_EXPIRED self.expiry = None self.removal = timezone.now() + timedelta(days=30) modified = True if self.state not in Billing.EXPIRING_STATES and self.expiry: self.expiry = None modified = True if self.in_limits != in_limits or self.paid != paid: self.in_limits = in_limits self.paid = paid modified = True if save and modified: self.save(skip_limits=True) def is_active(self): return self.state in (Billing.STATE_ACTIVE, Billing.STATE_TRIAL) def get_notify_users(self): users = self.owners.distinct() for project in self.projects.iterator(): users |= User.objects.having_perm("billing.view", project) return users def _get_libre_checklist(self): yield LibreCheck( self.count_projects == 1, ngettext("Contains %d project", "Contains %d projects", self.count_projects) % self.count_projects, ) for project in self.all_projects: yield LibreCheck( bool(project.web), mark_safe( '<a href="{0}">{1}</a>, <a href="{2}">{2}</a>'.format( escape(project.get_absolute_url()), escape(project), escape(project.web), )), ) components = Component.objects.filter(project__in=self.all_projects) yield LibreCheck( len(components) > 0, ngettext("Contains %d component", "Contains %d components", len(components)) % len(components), ) for component in components: yield LibreCheck( component.libre_license, mark_safe(""" <a href="{0}">{1}</a>, <a href="{2}">{3}</a>, <a href="{4}">{4}</a>, {5}""".format( escape(component.get_absolute_url()), escape(component.name), escape(component.license_url or "#"), escape(component.get_license_display() or _("Missing license")), escape(component.repo), escape(component.get_file_format_display()), )), ) @cached_property def libre_checklist(self): return list(self._get_libre_checklist()) @property def valid_libre(self): return all(self.libre_checklist)
class Billing(models.Model): STATE_ACTIVE = 0 STATE_TRIAL = 1 STATE_EXPIRED = 2 STATE_TERMINATED = 3 EXPIRING_STATES = (STATE_TRIAL,) plan = models.ForeignKey( Plan, on_delete=models.deletion.CASCADE, verbose_name=_("Billing plan") ) projects = models.ManyToManyField( Project, blank=True, verbose_name=_("Billed projects") ) owners = models.ManyToManyField(User, blank=True, verbose_name=_("Billing owners")) state = models.IntegerField( choices=( (STATE_ACTIVE, _("Active")), (STATE_TRIAL, _("Trial")), (STATE_EXPIRED, _("Expired")), (STATE_TERMINATED, _("Terminated")), ), default=STATE_ACTIVE, verbose_name=_("Billing state"), ) expiry = models.DateTimeField( blank=True, null=True, default=None, verbose_name=_("Trial expiry date"), help_text="After expiry removal with 15 days grace period is scheduled.", ) removal = models.DateTimeField( blank=True, null=True, default=None, verbose_name=_("Scheduled removal"), help_text="This is automatically set after trial expiry.", ) paid = models.BooleanField(default=True, verbose_name=_("Paid"), editable=False) # Translators: Whether the package is inside actual (hard) limits in_limits = models.BooleanField( default=True, verbose_name=_("In limits"), editable=False ) grace_period = models.IntegerField( default=0, verbose_name=_("Grace period for payments") ) # Payment detailed information, used for integration # with payment processor payment = JSONField(editable=False, default={}) objects = BillingManager.from_queryset(BillingQuerySet)() def __str__(self): projects = self.projects.order() owners = self.owners.order() if projects: base = ", ".join(str(x) for x in projects) elif owners: base = ", ".join(x.get_author_name(False) for x in owners) else: base = "Unassigned" return "{0} ({1})".format(base, self.plan) def get_absolute_url(self): return "{}#billing-{}".format(reverse("billing"), self.pk) @cached_property def can_be_paid(self): if self.state in (Billing.STATE_ACTIVE, Billing.STATE_TRIAL): return True return self.count_projects > 0 def count_changes(self, interval): return Change.objects.filter( component__project__in=self.projects.all(), timestamp__gt=timezone.now() - interval, ).count() @cached_property def count_changes_1m(self): return self.count_changes(timedelta(days=31)) count_changes_1m.short_description = _("Changes in last month") @cached_property def count_changes_1q(self): return self.count_changes(timedelta(days=93)) count_changes_1q.short_description = _("Changes in last quarter") @cached_property def count_changes_1y(self): return self.count_changes(timedelta(days=365)) count_changes_1y.short_description = _("Changes in last year") @cached_property def count_projects(self): return self.projects.count() def display_projects(self): return "{0} / {1}".format(self.count_projects, self.plan.display_limit_projects) display_projects.short_description = _("Projects") @cached_property def count_strings(self): return sum(p.stats.source_strings for p in self.projects.iterator()) def display_strings(self): return "{0} / {1}".format(self.count_strings, self.plan.display_limit_strings) display_strings.short_description = _("Source strings") @cached_property def count_words(self): return sum(p.stats.source_words for p in self.projects.iterator()) def display_words(self): return "{0}".format(self.count_words) display_words.short_description = _("Source words") @cached_property def count_languages(self): return ( Language.objects.filter( translation__component__project__in=self.projects.all() ) .distinct() .count() ) def display_languages(self): return "{0} / {1}".format( self.count_languages, self.plan.display_limit_languages ) display_languages.short_description = _("Languages") def flush_cache(self): keys = list(self.__dict__.keys()) for key in keys: if key.startswith("count_"): del self.__dict__[key] def check_in_limits(self, plan=None): if plan is None: plan = self.plan return ( (plan.limit_projects == 0 or self.count_projects <= plan.limit_projects) and (plan.limit_strings == 0 or self.count_strings <= plan.limit_strings) and ( plan.limit_languages == 0 or self.count_languages <= plan.limit_languages ) ) def check_expiry(self): return ( self.state in Billing.EXPIRING_STATES and self.expiry and self.expiry < timezone.now() ) def unit_count(self): return Unit.objects.filter( translation__component__project__in=self.projects.all() ).count() unit_count.short_description = _("Number of strings") def last_invoice(self): try: invoice = self.invoice_set.order_by("-start")[0] return "{0} - {1}".format(invoice.start, invoice.end) except IndexError: return _("N/A") last_invoice.short_description = _("Last invoice") def in_display_limits(self, plan=None): if plan is None: plan = self.plan return ( ( plan.display_limit_projects == 0 or self.count_projects <= plan.display_limit_projects ) and ( plan.display_limit_strings == 0 or self.count_strings <= plan.display_limit_strings ) and ( plan.display_limit_languages == 0 or self.count_languages <= plan.display_limit_languages ) ) in_display_limits.boolean = True # Translators: Whether the package is inside displayed (soft) limits in_display_limits.short_description = _("In display limits") def check_payment_status(self, grace=None): """Check current payment status. Compared to paid attribute, this does not include grace period. """ end = timezone.now() - timedelta(days=grace or self.grace_period) return ( self.plan.is_free or self.invoice_set.filter(end__gte=end).exists() or self.state == Billing.STATE_TRIAL ) def check_limits(self, grace=30, save=True): self.flush_cache() in_limits = self.check_in_limits() paid = self.check_payment_status(grace) modified = False if self.check_expiry(): self.state = Billing.STATE_EXPIRED self.expiry = None self.removal = timezone.now() + timedelta(days=30) modified = True if self.state not in Billing.EXPIRING_STATES and self.expiry: self.expiry = None modified = True if self.in_limits != in_limits or self.paid != paid: self.in_limits = in_limits self.paid = paid modified = True if save and modified: self.save(skip_limits=True) def save(self, *args, **kwargs): if not kwargs.pop("skip_limits", False) and self.pk: self.check_limits(save=False) super().save(*args, **kwargs) def is_active(self): return self.state in (Billing.STATE_ACTIVE, Billing.STATE_TRIAL) def get_notify_users(self): users = self.owners.distinct() for project in self.projects.iterator(): users |= User.objects.having_perm("billing.view", project) return users
class Payment(models.Model): NEW = 1 PENDING = 2 REJECTED = 3 ACCEPTED = 4 PROCESSED = 5 uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) amount = models.IntegerField() description = models.TextField() recurring = models.CharField( choices=RECURRENCE_CHOICES, default='', blank=True, max_length=10, ) created = models.DateTimeField(auto_now_add=True) state = models.IntegerField(choices=[ (NEW, 'New'), (PENDING, 'Pending'), (REJECTED, 'Rejected'), (ACCEPTED, 'Accepted'), (PROCESSED, 'Processed'), ], db_index=True, default=NEW) backend = models.CharField(max_length=100, default='', blank=True) # Payment details from the gateway details = JSONField(default={}, blank=True) # Payment extra information from the origin extra = JSONField(default={}, blank=True) customer = models.ForeignKey(Customer, on_delete=models.deletion.CASCADE, blank=True) repeat = models.ForeignKey('Payment', on_delete=models.deletion.CASCADE, null=True, blank=True) invoice = models.CharField(max_length=20, blank=True, default='') amount_fixed = models.BooleanField(blank=True, default=False) class Meta: ordering = ['-created'] @cached_property def invoice_filename(self): return '{0}.pdf'.format(self.invoice) @cached_property def invoice_full_filename(self): return os.path.join(settings.PAYMENT_FAKTURACE, 'pdf', self.invoice_filename) @cached_property def invoice_filename_valid(self): return os.path.exists(self.invoice_full_filename) @property def vat_amount(self): if self.customer.needs_vat and not self.amount_fixed: rate = 100 + self.customer.vat_rate return round(1.0 * rate * self.amount / 100, 2) return self.amount @property def amount_without_vat(self): if self.customer.needs_vat and self.amount_fixed: return 100.0 * self.amount / (100 + self.customer.vat_rate) return self.amount def get_payment_url(self): language = get_language() if language not in SUPPORTED_LANGUAGES: language = 'en' return settings.PAYMENT_REDIRECT_URL.format(language=language, uuid=self.uuid) def repeat_payment(self, **kwargs): # Check if backend is still valid from wlhosted.payments.backends import get_backend try: get_backend(self.backend) except KeyError: return False with transaction.atomic(using='payments_db'): # Check for failed payments previous = Payment.objects.filter(repeat=self) if previous.exists(): failures = previous.filter(state=Payment.REJECTED) try: last_good = previous.filter( state=Payment.PROCESSED).order_by('-created')[0] failures = failures.filter(created__gt=last_good.created) except IndexError: pass if failures.count() >= 3: return False # Create new payment object extra = {} extra.update(self.extra) extra.update(kwargs) payment = Payment.objects.create(amount=self.amount, backend=self.backend, description=self.description, recurring='', customer=self.customer, amount_fixed=self.amount_fixed, repeat=self, extra=extra) return payment def trigger_remotely(self): # Trigger payment processing remotely requests.post(self.get_payment_url(), allow_redirects=False, data={ 'method': self.backend, 'secret': settings.PAYMENT_SECRET, })
class Change(models.Model, UserDisplayMixin): ACTION_UPDATE = 0 ACTION_COMPLETE = 1 ACTION_CHANGE = 2 ACTION_COMMENT = 3 ACTION_SUGGESTION = 4 ACTION_NEW = 5 ACTION_AUTO = 6 ACTION_ACCEPT = 7 ACTION_REVERT = 8 ACTION_UPLOAD = 9 ACTION_DICTIONARY_NEW = 10 ACTION_DICTIONARY_EDIT = 11 ACTION_DICTIONARY_UPLOAD = 12 ACTION_NEW_SOURCE = 13 ACTION_LOCK = 14 ACTION_UNLOCK = 15 ACTION_DUPLICATE_STRING = 16 ACTION_COMMIT = 17 ACTION_PUSH = 18 ACTION_RESET = 19 ACTION_MERGE = 20 ACTION_REBASE = 21 ACTION_FAILED_MERGE = 22 ACTION_FAILED_REBASE = 23 ACTION_PARSE_ERROR = 24 ACTION_REMOVE_TRANSLATION = 25 ACTION_SUGGESTION_DELETE = 26 ACTION_REPLACE = 27 ACTION_FAILED_PUSH = 28 ACTION_SUGGESTION_CLEANUP = 29 ACTION_SOURCE_CHANGE = 30 ACTION_NEW_UNIT = 31 ACTION_BULK_EDIT = 32 ACTION_ACCESS_EDIT = 33 ACTION_ADD_USER = 34 ACTION_REMOVE_USER = 35 ACTION_APPROVE = 36 ACTION_MARKED_EDIT = 37 ACTION_REMOVE_COMPONENT = 38 ACTION_REMOVE_PROJECT = 39 ACTION_DUPLICATE_LANGUAGE = 40 ACTION_RENAME_PROJECT = 41 ACTION_RENAME_COMPONENT = 42 ACTION_MOVE_COMPONENT = 43 ACTION_NEW_STRING = 44 ACTION_NEW_CONTRIBUTOR = 45 ACTION_MESSAGE = 46 ACTION_ALERT = 47 ACTION_ADDED_LANGUAGE = 48 ACTION_REQUESTED_LANGUAGE = 49 ACTION_CREATE_PROJECT = 50 ACTION_CREATE_COMPONENT = 51 ACTION_INVITE_USER = 52 ACTION_HOOK = 53 ACTION_REPLACE_UPLOAD = 54 ACTION_CHOICES = ( # Translators: Name of event in the history (ACTION_UPDATE, gettext_lazy("Resource update")), # Translators: Name of event in the history (ACTION_COMPLETE, gettext_lazy("Translation completed")), # Translators: Name of event in the history (ACTION_CHANGE, gettext_lazy("Translation changed")), # Translators: Name of event in the history (ACTION_NEW, gettext_lazy("New translation")), # Translators: Name of event in the history (ACTION_COMMENT, gettext_lazy("Comment added")), # Translators: Name of event in the history (ACTION_SUGGESTION, gettext_lazy("Suggestion added")), # Translators: Name of event in the history (ACTION_AUTO, gettext_lazy("Automatic translation")), # Translators: Name of event in the history (ACTION_ACCEPT, gettext_lazy("Suggestion accepted")), # Translators: Name of event in the history (ACTION_REVERT, gettext_lazy("Translation reverted")), # Translators: Name of event in the history (ACTION_UPLOAD, gettext_lazy("Translation uploaded")), # Translators: Name of event in the history (ACTION_DICTIONARY_NEW, gettext_lazy("Added to glossary")), # Translators: Name of event in the history (ACTION_DICTIONARY_EDIT, gettext_lazy("Glossary updated")), # Translators: Name of event in the history (ACTION_DICTIONARY_UPLOAD, gettext_lazy("Glossary uploaded")), # Translators: Name of event in the history (ACTION_NEW_SOURCE, gettext_lazy("New source string")), # Translators: Name of event in the history (ACTION_LOCK, gettext_lazy("Component locked")), # Translators: Name of event in the history (ACTION_UNLOCK, gettext_lazy("Component unlocked")), # Translators: Name of event in the history (ACTION_DUPLICATE_STRING, gettext_lazy("Found duplicated string")), # Translators: Name of event in the history (ACTION_COMMIT, gettext_lazy("Committed changes")), # Translators: Name of event in the history (ACTION_PUSH, gettext_lazy("Pushed changes")), # Translators: Name of event in the history (ACTION_RESET, gettext_lazy("Reset repository")), # Translators: Name of event in the history (ACTION_MERGE, gettext_lazy("Merged repository")), # Translators: Name of event in the history (ACTION_REBASE, gettext_lazy("Rebased repository")), # Translators: Name of event in the history (ACTION_FAILED_MERGE, gettext_lazy("Failed merge on repository")), # Translators: Name of event in the history (ACTION_FAILED_REBASE, gettext_lazy("Failed rebase on repository")), # Translators: Name of event in the history (ACTION_FAILED_PUSH, gettext_lazy("Failed push on repository")), # Translators: Name of event in the history (ACTION_PARSE_ERROR, gettext_lazy("Parse error")), # Translators: Name of event in the history (ACTION_REMOVE_TRANSLATION, gettext_lazy("Removed translation")), # Translators: Name of event in the history (ACTION_SUGGESTION_DELETE, gettext_lazy("Suggestion removed")), # Translators: Name of event in the history (ACTION_REPLACE, gettext_lazy("Search and replace")), # Translators: Name of event in the history (ACTION_SUGGESTION_CLEANUP, gettext_lazy("Suggestion removed during cleanup")), # Translators: Name of event in the history (ACTION_SOURCE_CHANGE, gettext_lazy("Source string changed")), # Translators: Name of event in the history (ACTION_NEW_UNIT, gettext_lazy("New string added")), # Translators: Name of event in the history (ACTION_BULK_EDIT, gettext_lazy("Bulk status change")), # Translators: Name of event in the history (ACTION_ACCESS_EDIT, gettext_lazy("Changed visibility")), # Translators: Name of event in the history (ACTION_ADD_USER, gettext_lazy("Added user")), # Translators: Name of event in the history (ACTION_REMOVE_USER, gettext_lazy("Removed user")), # Translators: Name of event in the history (ACTION_APPROVE, gettext_lazy("Translation approved")), # Translators: Name of event in the history (ACTION_MARKED_EDIT, gettext_lazy("Marked for edit")), # Translators: Name of event in the history (ACTION_REMOVE_COMPONENT, gettext_lazy("Removed component")), # Translators: Name of event in the history (ACTION_REMOVE_PROJECT, gettext_lazy("Removed project")), # Translators: Name of event in the history (ACTION_DUPLICATE_LANGUAGE, gettext_lazy("Found duplicated language")), # Translators: Name of event in the history (ACTION_RENAME_PROJECT, gettext_lazy("Renamed project")), # Translators: Name of event in the history (ACTION_RENAME_COMPONENT, gettext_lazy("Renamed component")), # Translators: Name of event in the history (ACTION_MOVE_COMPONENT, gettext_lazy("Moved component")), # Translators: Name of event in the history (ACTION_NEW_STRING, gettext_lazy("New string to translate")), # Translators: Name of event in the history (ACTION_NEW_CONTRIBUTOR, gettext_lazy("New contributor")), # Translators: Name of event in the history (ACTION_MESSAGE, gettext_lazy("New announcement")), # Translators: Name of event in the history (ACTION_ALERT, gettext_lazy("New alert")), # Translators: Name of event in the history (ACTION_ADDED_LANGUAGE, gettext_lazy("Added new language")), # Translators: Name of event in the history (ACTION_REQUESTED_LANGUAGE, gettext_lazy("Requested new language")), # Translators: Name of event in the history (ACTION_CREATE_PROJECT, gettext_lazy("Created project")), # Translators: Name of event in the history (ACTION_CREATE_COMPONENT, gettext_lazy("Created component")), # Translators: Name of event in the history (ACTION_INVITE_USER, gettext_lazy("Invited user")), # Translators: Name of event in the history (ACTION_HOOK, gettext_lazy("Received repository notification")), # Translators: Name of event in the history (ACTION_REPLACE_UPLOAD, gettext_lazy("Replaced file by upload")), ) # Actions which can be reverted ACTIONS_REVERTABLE = { ACTION_ACCEPT, ACTION_REVERT, ACTION_CHANGE, ACTION_UPLOAD, ACTION_NEW, ACTION_REPLACE, ACTION_AUTO, ACTION_APPROVE, ACTION_MARKED_EDIT, } # Content changes considered when looking for last author ACTIONS_CONTENT = { ACTION_CHANGE, ACTION_NEW, ACTION_AUTO, ACTION_ACCEPT, ACTION_REVERT, ACTION_UPLOAD, ACTION_REPLACE, ACTION_BULK_EDIT, ACTION_APPROVE, ACTION_MARKED_EDIT, } # Actions considered as being translated in consistency check ACTIONS_TRANSLATED = { ACTION_CHANGE, ACTION_NEW, ACTION_AUTO, ACTION_ACCEPT, ACTION_REVERT, ACTION_UPLOAD, ACTION_REPLACE, ACTION_APPROVE, } # Actions shown on the repository management page ACTIONS_REPOSITORY = { ACTION_COMMIT, ACTION_PUSH, ACTION_RESET, ACTION_MERGE, ACTION_REBASE, ACTION_FAILED_MERGE, ACTION_FAILED_REBASE, ACTION_FAILED_PUSH, ACTION_LOCK, ACTION_UNLOCK, ACTION_DUPLICATE_LANGUAGE, } # Actions where target is rendered as translation string ACTIONS_SHOW_CONTENT = { ACTION_SUGGESTION, ACTION_SUGGESTION_DELETE, ACTION_SUGGESTION_CLEANUP, ACTION_NEW_UNIT, ACTION_DICTIONARY_NEW, ACTION_DICTIONARY_EDIT, } # Actions indicating a repository merge failure ACTIONS_MERGE_FAILURE = { ACTION_FAILED_MERGE, ACTION_FAILED_REBASE, ACTION_FAILED_PUSH, } unit = models.ForeignKey("Unit", null=True, on_delete=models.deletion.CASCADE) language = models.ForeignKey("lang.Language", null=True, on_delete=models.deletion.CASCADE) project = models.ForeignKey("Project", null=True, on_delete=models.deletion.CASCADE) component = models.ForeignKey("Component", null=True, on_delete=models.deletion.CASCADE) translation = models.ForeignKey("Translation", null=True, on_delete=models.deletion.CASCADE) dictionary = models.ForeignKey("Dictionary", null=True, on_delete=models.deletion.CASCADE) comment = models.ForeignKey("Comment", null=True, on_delete=models.deletion.SET_NULL) suggestion = models.ForeignKey("Suggestion", null=True, on_delete=models.deletion.SET_NULL) announcement = models.ForeignKey("Announcement", null=True, on_delete=models.deletion.SET_NULL) alert = models.ForeignKey("Alert", null=True, on_delete=models.deletion.SET_NULL) user = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, on_delete=models.deletion.CASCADE) author = models.ForeignKey( settings.AUTH_USER_MODEL, null=True, related_name="author_set", on_delete=models.deletion.CASCADE, ) timestamp = models.DateTimeField(auto_now_add=True) action = models.IntegerField(choices=ACTION_CHOICES, default=ACTION_CHANGE, db_index=True) target = models.TextField(default="", blank=True) old = models.TextField(default="", blank=True) details = JSONField() objects = ChangeManager.from_queryset(ChangeQuerySet)() class Meta: app_label = "trans" index_together = [ ("timestamp", "translation"), ] def __init__(self, *args, **kwargs): self.notify_state = {} super().__init__(*args, **kwargs) def __str__(self): return _("%(action)s at %(time)s on %(translation)s by %(user)s") % { "action": self.get_action_display(), "time": self.timestamp, "translation": self.translation, "user": self.get_user_display(False), } def is_merge_failure(self): return self.action in self.ACTIONS_MERGE_FAILURE def get_absolute_url(self): """Return link either to unit or translation.""" if self.unit is not None: return self.unit.get_absolute_url() if self.translation is not None: return self.translation.get_absolute_url() if self.component is not None: return self.component.get_absolute_url() if self.dictionary is not None: return self.dictionary.get_parent_url() if self.project is not None: return self.project.get_absolute_url() return None def can_revert(self): return (self.unit is not None and self.old and self.action in self.ACTIONS_REVERTABLE) def show_source(self): """Whether to show content as source change.""" return self.action == self.ACTION_SOURCE_CHANGE def show_content(self): """Whether to show content as translation.""" return (self.action in self.ACTIONS_SHOW_CONTENT or self.action in self.ACTIONS_REVERTABLE) def get_details_display(self): from weblate.utils.markdown import render_markdown if not self.details: return "" user_actions = { self.ACTION_ADD_USER, self.ACTION_INVITE_USER, self.ACTION_REMOVE_USER, } if self.action == self.ACTION_ACCESS_EDIT: for number, name in Project.ACCESS_CHOICES: if number == self.details["access_control"]: return name return "Unknonwn {}".format(self.details["access_control"]) if self.action in user_actions: if "group" in self.details: return "{username} ({group})".format(**self.details) return self.details["username"] if self.action in ( self.ACTION_ADDED_LANGUAGE, self.ACTION_REQUESTED_LANGUAGE, ): # noqa: E501 try: return Language.objects.get(code=self.details["language"]) except Language.DoesNotExist: return self.details["language"] if self.action == self.ACTION_ALERT: try: return ALERTS[self.details["alert"]].verbose except KeyError: return self.details["alert"] if self.action == self.ACTION_PARSE_ERROR: return "{filename}: {error_message}".format(**self.details) if self.action == self.ACTION_HOOK: return "{service_long_name}: {repo_url}, {branch}".format( **self.details) if self.action == self.ACTION_COMMENT and "comment" in self.details: return render_markdown(self.details["comment"]) return "" def save(self, *args, **kwargs): from weblate.accounts.tasks import notify_change if self.unit: self.translation = self.unit.translation if self.translation: self.component = self.translation.component self.language = self.translation.language if self.component: self.project = self.component.project if self.dictionary: self.project = self.dictionary.project self.language = self.dictionary.language super().save(*args, **kwargs) transaction.on_commit(lambda: notify_change.delay(self.pk)) def get_distance(self): return damerau_levenshtein_distance(self.old, self.target)
class AuditLog(models.Model): """User audit log storage.""" user = models.ForeignKey(User, on_delete=models.deletion.CASCADE) activity = models.CharField( max_length=20, choices=[(a, a) for a in sorted(ACCOUNT_ACTIVITY.keys())], db_index=True, ) params = JSONField() address = models.GenericIPAddressField(null=True) user_agent = models.CharField(max_length=200, default='') timestamp = models.DateTimeField(auto_now_add=True, db_index=True) objects = AuditLogManager.from_queryset(AuditLogQuerySet)() def get_params(self): result = {} result.update(self.params) if 'method' in result: result['method'] = ugettext(result['method']) return result def get_message(self): method = self.params.get('method') activity = self.activity if activity in ACCOUNT_ACTIVITY_METHOD.get(method, {}): message = ACCOUNT_ACTIVITY_METHOD[method][activity] else: message = ACCOUNT_ACTIVITY[activity] return message.format(**self.get_params()) get_message.short_description = _('Account activity') def get_extra_message(self): if self.activity in EXTRA_MESSAGES: return EXTRA_MESSAGES[self.activity].format(**self.params) return None def should_notify(self): return self.activity in NOTIFY_ACTIVITY def __str__(self): return '{0} for {1} from {2}'.format(self.activity, self.user.username, self.address) def check_rate_limit(self, request): """Check whether the activity should be rate limited.""" if self.activity == 'failed-auth' and self.user.has_usable_password(): failures = AuditLog.objects.get_after(self.user, 'login', 'failed-auth') if failures.count() >= settings.AUTH_LOCK_ATTEMPTS: self.user.set_unusable_password() self.user.save(update_fields=['password']) AuditLog.objects.create(self.user, request, 'locked') return True elif self.activity == 'reset-request': failures = AuditLog.objects.filter( timestamp__gte=timezone.now() - datetime.timedelta(days=1), activity='reset-request', ) if failures.count() >= settings.AUTH_LOCK_ATTEMPTS: return True return False def save(self, *args, **kwargs): super(AuditLog, self).save(*args, **kwargs) if self.should_notify(): notify_auditlog.delay(self.pk, self.user.email)
class Suggestion(UnitData, UserDisplayMixin): target = models.TextField() user = models.ForeignKey( settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.deletion.CASCADE, ) userdetails = JSONField() language = models.ForeignKey(Language, on_delete=models.deletion.CASCADE) timestamp = models.DateTimeField(auto_now_add=True) votes = models.ManyToManyField( settings.AUTH_USER_MODEL, through='Vote', related_name='user_votes' ) objects = SuggestionManager.from_queryset(SuggestionQuerySet)() class Meta(object): app_label = 'trans' index_together = [('project', 'language', 'content_hash')] def __str__(self): return 'suggestion for {0} by {1}'.format( self.content_hash, self.user.username if self.user else 'unknown' ) @transaction.atomic def accept(self, translation, request, permission='suggestion.accept'): allunits = translation.unit_set.select_for_update().filter( content_hash=self.content_hash ) failure = False for unit in allunits: if not request.user.has_perm(permission, unit): failure = True messages.error(request, _('Failed to accept suggestion!')) continue # Skip if there is no change if unit.target == self.target and unit.state >= STATE_TRANSLATED: continue unit.target = self.target unit.state = STATE_TRANSLATED unit.save_backend(request.user, change_action=Change.ACTION_ACCEPT) if not failure: self.delete() def delete_log(self, user, change=Change.ACTION_SUGGESTION_DELETE, is_spam=False): """Delete with logging change""" if is_spam and self.userdetails: report_spam( self.userdetails['address'], self.userdetails['agent'], self.target ) for unit in self.related_units: Change.objects.create( unit=unit, action=change, user=user, target=self.target, author=user ) self.delete() def get_num_votes(self): """Return number of votes.""" return self.vote_set.aggregate(Sum('value'))['value__sum'] or 0 def add_vote(self, translation, request, value): """Add (or updates) vote for a suggestion.""" if not request.user.is_authenticated: return vote, created = Vote.objects.get_or_create( suggestion=self, user=request.user, defaults={'value': value} ) if not created or vote.value != value: vote.value = value vote.save() # Automatic accepting required_votes = translation.component.suggestion_autoaccept if required_votes and self.get_num_votes() >= required_votes: self.accept(translation, request, 'suggestion.vote')
class Addon(models.Model): component = models.ForeignKey(Component, on_delete=models.deletion.CASCADE) name = models.CharField(max_length=100) configuration = JSONField() state = JSONField() project_scope = models.BooleanField(default=False, db_index=True) repo_scope = models.BooleanField(default=False, db_index=True) objects = AddonQuerySet.as_manager() class Meta: verbose_name = "add-on" verbose_name_plural = "add-ons" def __str__(self): return f"{self.addon.verbose}: {self.component}" def save( self, force_insert=False, force_update=False, using=None, update_fields=None ): cls = self.addon_class self.project_scope = cls.project_scope self.repo_scope = cls.repo_scope if self.component: # Reallocate to repository if self.repo_scope and self.component.linked_component: self.component = self.component.linked_component # Clear add-on cache self.component.drop_addons_cache() return super().save( force_insert=force_insert, force_update=force_update, using=using, update_fields=update_fields, ) def get_absolute_url(self): return reverse( "addon-detail", kwargs={ "project": self.component.project.slug, "component": self.component.slug, "pk": self.pk, }, ) def configure_events(self, events): for event in events: Event.objects.get_or_create(addon=self, event=event) self.event_set.exclude(event__in=events).delete() @cached_property def addon_class(self): return ADDONS[self.name] @cached_property def addon(self): return self.addon_class(self) def delete(self, *args, **kwargs): # Delete any addon alerts if self.addon.alert: self.component.delete_alert(self.addon.alert) super().delete(*args, **kwargs) def disable(self): self.component.log_warning( "disabling no longer compatible add-on: %s", self.name ) self.delete()