class ScheduledBanner(models.Model): class Meta: # Custom permissions for use in the OSF Admin App permissions = (('view_scheduledbanner', 'Can view scheduled banner details'), ) name = models.CharField(unique=True, max_length=256) start_date = NonNaiveDateTimeField() end_date = NonNaiveDateTimeField() color = models.CharField(max_length=7) license = models.CharField(blank=True, null=True, max_length=256) link = models.URLField( blank=True, default='https://www.crowdrise.com/centerforopenscience') default_photo = models.FileField(storage=BannerImageStorage()) default_alt_text = models.TextField() mobile_photo = models.FileField(storage=BannerImageStorage()) mobile_alt_text = models.TextField(blank=True, null=True) def save(self, *args, **kwargs): self.start_date = datetime.combine(self.start_date, datetime.min.time()) self.end_date = datetime.combine(self.end_date, datetime.max.time()) validate_banner_dates(self.id, self.start_date, self.end_date) super(ScheduledBanner, self).save(*args, **kwargs)
class Conference(ObjectIDMixin, BaseModel): #: Determines the email address for submission and the OSF url # Example: If endpoint is spsp2014, then submission email will be # [email protected] or [email protected] and the OSF url will # be osf.io/view/spsp2014 endpoint = models.CharField(max_length=255, unique=True, db_index=True) #: Full name, e.g. "SPSP 2014" name = models.CharField(max_length=255) info_url = models.URLField(blank=True) logo_url = models.URLField(blank=True) location = models.CharField(max_length=2048, null=True, blank=True) start_date = NonNaiveDateTimeField(blank=True, null=True) end_date = NonNaiveDateTimeField(blank=True, null=True) is_meeting = models.BooleanField(default=True) active = models.BooleanField() admins = models.ManyToManyField('OSFUser') #: Whether to make submitted projects public public_projects = models.BooleanField(default=True) poster = models.BooleanField(default=True) talk = models.BooleanField(default=True) # field_names are used to customize the text on the conference page, the categories # of submissions, and the email adress to send material to. field_names = DateTimeAwareJSONField(default=get_default_field_names) # Cached number of submissions num_submissions = models.IntegerField(default=0) objects = ConferenceManager() def __repr__(self): return ( '<Conference(endpoint={self.endpoint!r}, active={self.active})>'. format(self=self)) @classmethod def get_by_endpoint(cls, endpoint, active): return cls.objects.get_by_endpoint(endpoint, active) @property def absolute_url(self): return urlparse.urljoin(settings.DOMAIN, '/view/{}'.format(self.endpoint)) @property def submissions(self): """ Returns valid conference submissions with at least one file attached """ tags = Tag.objects.filter( system=False, name__iexact=self.endpoint).values_list('pk', flat=True) return AbstractNode.objects.filter(tags__in=tags, is_public=True, is_deleted=False) class Meta: # custom permissions for use in the OSF Admin App permissions = (('view_conference', 'Can view conference details in the admin app.'), )
class QueuedMail(ObjectIDMixin, BaseModel): user = models.ForeignKey('OSFUser', db_index=True, null=True) to_addr = models.CharField(max_length=255) send_at = NonNaiveDateTimeField(db_index=True, null=False) # string denoting the template, presend to be used. Has to be an index of queue_mail types email_type = models.CharField(max_length=255, db_index=True, null=False) # dictionary with variables used to populate mako template and store information used in presends # Example: # self.data = { # 'nid' : 'ShIpTo', # 'fullname': 'Florence Welch', #} data = DateTimeAwareJSONField(default=dict, blank=True) sent_at = NonNaiveDateTimeField(db_index=True, null=True, blank=True) def __repr__(self): if self.sent_at is not None: return '<QueuedMail {} of type {} sent to {} at {}>'.format( self._id, self.email_type, self.to_addr, self.sent_at) return '<QueuedMail {} of type {} to be sent to {} on {}>'.format( self._id, self.email_type, self.to_addr, self.send_at) def send_mail(self): """ Grabs the data from this email, checks for user subscription to help mails, constructs the mail object and checks presend. Then attempts to send the email through send_mail() :return: boolean based on whether email was sent. """ mail_struct = queue_mail_types[self.email_type] presend = mail_struct['presend'](self) mail = Mail(mail_struct['template'], subject=mail_struct['subject'], categories=mail_struct.get('categories', None)) self.data['osf_url'] = osf_settings.DOMAIN if presend and self.user.is_active and self.user.osf_mailing_lists.get( osf_settings.OSF_HELP_LIST): send_mail(self.to_addr or self.user.username, mail, mimetype='html', **(self.data or {})) self.sent_at = timezone.now() self.save() return True else: self.__class__.remove_one(self) return False def find_sent_of_same_type_and_user(self): """ Queries up for all emails of the same type as self, sent to the same user as self. Does not look for queue-up emails. :return: a list of those emails """ return self.__class__.objects.filter( email_type=self.email_type, user=self.user).exclude(sent_at=None)
class ExternalAccount(base.ObjectIDMixin, base.BaseModel): """An account on an external service. Note that this object is not and should not be aware of what other objects are associated with it. This is by design, and this object should be kept as thin as possible, containing only those fields that must be stored in the database. The ``provider`` field is a de facto foreign key to an ``ExternalProvider`` object, as providers are not stored in the database. """ # The OAuth credentials. One or both of these fields should be populated. # For OAuth1, this is usually the "oauth_token" # For OAuth2, this is usually the "access_token" oauth_key = EncryptedTextField(blank=True, null=True) # For OAuth1, this is usually the "oauth_token_secret" # For OAuth2, this is not used oauth_secret = EncryptedTextField(blank=True, null=True) # Used for OAuth2 only refresh_token = EncryptedTextField(blank=True, null=True) date_last_refreshed = NonNaiveDateTimeField(blank=True, null=True) expires_at = NonNaiveDateTimeField(blank=True, null=True) scopes = ArrayField(models.CharField(max_length=128), default=list, blank=True) # The `name` of the service # This lets us query for only accounts on a particular provider # TODO We should make provider an actual FK someday. provider = models.CharField(max_length=50, blank=False, null=False) # The proper 'name' of the service # Needed for account serialization provider_name = models.CharField(max_length=255, blank=False, null=False) # The unique, persistent ID on the remote service. provider_id = models.CharField(max_length=255, blank=False, null=False) # The user's name on the external service display_name = EncryptedTextField(blank=True, null=True) # A link to the user's profile on the external service profile_url = EncryptedTextField(blank=True, null=True) def __repr__(self): return '<ExternalAccount: {}/{}>'.format(self.provider, self.provider_id) def _natural_key(self): if self.pk: return self.pk return hash(str(self.provider_id) + str(self.provider)) class Meta: unique_together = [( 'provider', 'provider_id', )]
class Conference(ObjectIDMixin, BaseModel): #: Determines the email address for submission and the OSF url # Example: If endpoint is spsp2014, then submission email will be # [email protected] or [email protected] and the OSF url will # be osf.io/view/spsp2014 endpoint = models.CharField(max_length=255, unique=True, db_index=True) #: Full name, e.g. "SPSP 2014" name = models.CharField(max_length=255) info_url = models.URLField(blank=True) logo_url = models.URLField(blank=True) location = models.CharField(max_length=2048, null=True, blank=True) start_date = NonNaiveDateTimeField(blank=True, null=True) end_date = NonNaiveDateTimeField(blank=True, null=True) is_meeting = models.BooleanField(default=True) active = models.BooleanField() admins = models.ManyToManyField('OSFUser') # Temporary field on conference model to link Conferences and AbstractNodes submissions = models.ManyToManyField('AbstractNode', related_name='conferences') # Whether to make submitted projects public public_projects = models.BooleanField(default=True) poster = models.BooleanField(default=True) talk = models.BooleanField(default=True) # field_names are used to customize the text on the conference page, the categories # of submissions, and the email adress to send material to. field_names = DateTimeAwareJSONField(default=get_default_field_names) auto_check_spam = models.BooleanField(default=True) objects = ConferenceManager() def __repr__(self): return ( '<Conference(endpoint={self.endpoint!r}, active={self.active})>'. format(self=self)) @classmethod def get_by_endpoint(cls, endpoint, active): return cls.objects.get_by_endpoint(endpoint, active) @property def absolute_url(self): return urljoin(settings.DOMAIN, '/view/{}'.format(self.endpoint)) @property def valid_submissions(self): """ Returns valid conference submissions - nodes can't be public or deleted """ return self.submissions.filter(is_public=True, is_deleted=False) class Meta: # custom permissions for use in the OSF Admin App permissions = (('view_conference', 'Can view conference details in the admin app.'), )
class Session(ObjectIDMixin, BaseModel): date_created = NonNaiveDateTimeField(auto_now_add=True) date_modified = NonNaiveDateTimeField(auto_now=True) data = DateTimeAwareJSONField(default=dict, blank=True) @property def is_authenticated(self): return 'auth_user_id' in self.data @property def is_external_first_login(self): return 'auth_user_external_first_login' in self.data
class CitationStyle(BaseModel): """Persistent representation of a CSL style. These are parsed from .csl files, so that metadata fields can be indexed. """ primary_identifier_name = '_id' # The name of the citation file, sans extension _id = models.CharField(max_length=255, db_index=True) # The full title of the style title = models.CharField(max_length=255) # Datetime the file was last parsed date_parsed = NonNaiveDateTimeField(default=timezone.now) short_title = models.CharField(max_length=2048, null=True, blank=True) summary = models.CharField(max_length=4200, null=True, blank=True) # longest value was 3,812 8/23/2016 class Meta: ordering = ['_id'] def to_json(self): return { 'id': self._id, 'title': self.title, 'short_title': self.short_title, 'summary': self.summary, }
class RdmAnnouncement(BaseModel): user = models.ForeignKey('OSFUser', null=True) title = models.CharField(max_length=256, blank=True, null=False) body = models.TextField(max_length=63206, null=False) announcement_type = models.CharField(max_length=256, null=False) date_sent = NonNaiveDateTimeField(auto_now_add=True) is_success = models.BooleanField(default=False)
class Action(ObjectIDMixin, BaseModel): objects = IncludeManager() target = models.ForeignKey('PreprintService', related_name='actions') creator = models.ForeignKey('OSFUser', related_name='+') trigger = models.CharField(max_length=31, choices=Triggers.choices()) from_state = models.CharField(max_length=31, choices=States.choices()) to_state = models.CharField(max_length=31, choices=States.choices()) comment = models.TextField(blank=True) is_deleted = models.BooleanField(default=False) date_created = NonNaiveDateTimeField(auto_now_add=True) date_modified = NonNaiveDateTimeField(auto_now=True)
class Guid(BaseModel): """Stores either a short guid or long object_id for any model that inherits from BaseIDMixin. Each ID field (e.g. 'guid', 'object_id') MUST have an accompanying method, named with 'initialize_<ID type>' (e.g. 'initialize_guid') that generates and sets the field. """ primary_identifier_name = '_id' id = models.AutoField(primary_key=True) _id = LowercaseCharField(max_length=255, null=False, blank=False, default=generate_guid, db_index=True, unique=True) referent = GenericForeignKey() content_type = models.ForeignKey(ContentType, null=True, blank=True) object_id = models.PositiveIntegerField(null=True, blank=True) created = NonNaiveDateTimeField(db_index=True, auto_now_add=True) def __repr__(self): return '<id:{0}, referent:({1})>'.format(self._id, self.referent.__repr__()) # Override load in order to load by GUID @classmethod def load(cls, data): try: return cls.objects.get(_id=data) except cls.DoesNotExist: return None class Meta: ordering = ['-created'] get_latest_by = 'created' index_together = ( ('content_type', 'object_id', 'created'), )
class RecentlyAddedContributor(models.Model): user = models.ForeignKey('OSFUser', on_delete=models.CASCADE) # the user who added the contributor contributor = models.ForeignKey('OSFUser', related_name='recently_added_by', on_delete=models.CASCADE) # the added contributor date_added = NonNaiveDateTimeField(auto_now=True) class Meta: unique_together = ('user', 'contributor')
class DraftRegistrationLog(ObjectIDMixin, BaseModel): """ Simple log to show status changes for DraftRegistrations field - _id - primary key field - date - date of the action took place field - action - simple action to track what happened field - user - user who did the action """ date = NonNaiveDateTimeField(default=timezone.now) action = models.CharField(max_length=255) draft = models.ForeignKey('DraftRegistration', related_name='logs', null=True, blank=True, on_delete=models.CASCADE) user = models.ForeignKey('OSFUser', null=True, on_delete=models.CASCADE) SUBMITTED = 'submitted' REGISTERED = 'registered' APPROVED = 'approved' REJECTED = 'rejected' def __repr__(self): return ('<DraftRegistrationLog({self.action!r}, date={self.date!r}), ' 'user={self.user!r} ' 'with id {self._id!r}>').format(self=self)
class DraftRegistrationLog(ObjectIDMixin, BaseModel): """ Simple log to show status changes for DraftRegistrations Also, editable fields on registrations are logged. field - _id - primary key field - date - date of the action took place field - action - simple action to track what happened field - user - user who did the action """ date = NonNaiveDateTimeField(default=timezone.now) action = models.CharField(max_length=255) draft = models.ForeignKey('DraftRegistration', related_name='logs', null=True, blank=True, on_delete=models.CASCADE) user = models.ForeignKey('OSFUser', db_index=True, null=True, blank=True, on_delete=models.CASCADE) params = DateTimeAwareJSONField(default=dict) SUBMITTED = 'submitted' REGISTERED = 'registered' APPROVED = 'approved' REJECTED = 'rejected' EDITED_TITLE = 'edit_title' EDITED_DESCRIPTION = 'edit_description' CATEGORY_UPDATED = 'category_updated' CONTRIB_ADDED = 'contributor_added' CONTRIB_REMOVED = 'contributor_removed' CONTRIB_REORDERED = 'contributors_reordered' PERMISSIONS_UPDATED = 'permissions_updated' MADE_CONTRIBUTOR_VISIBLE = 'made_contributor_visible' MADE_CONTRIBUTOR_INVISIBLE = 'made_contributor_invisible' AFFILIATED_INSTITUTION_ADDED = 'affiliated_institution_added' AFFILIATED_INSTITUTION_REMOVED = 'affiliated_institution_removed' CHANGED_LICENSE = 'license_changed' TAG_ADDED = 'tag_added' TAG_REMOVED = 'tag_removed' def __repr__(self): return ('<DraftRegistrationLog({self.action!r}, date={self.date!r}), ' 'user={self.user!r} ' 'with id {self._id!r}>').format(self=self) class Meta: ordering = ['-created'] get_latest_by = 'created'
class Conference(ObjectIDMixin, BaseModel): #: Determines the email address for submission and the OSF url # Example: If endpoint is spsp2014, then submission email will be # [email protected] or [email protected] and the OSF url will # be osf.io/view/spsp2014 endpoint = models.CharField(max_length=255, unique=True, db_index=True) #: Full name, e.g. "SPSP 2014" name = models.CharField(max_length=255) info_url = models.URLField(blank=True) logo_url = models.URLField(blank=True) location = models.CharField(max_length=2048, null=True, blank=True) start_date = NonNaiveDateTimeField(blank=True, null=True) end_date = NonNaiveDateTimeField(blank=True, null=True) is_meeting = models.BooleanField(default=True) active = models.BooleanField() admins = models.ManyToManyField('OSFUser') #: Whether to make submitted projects public public_projects = models.BooleanField(default=True) poster = models.BooleanField(default=True) talk = models.BooleanField(default=True) # field_names are used to customize the text on the conference page, the categories # of submissions, and the email adress to send material to. field_names = DateTimeAwareJSONField(default=get_default_field_names) # Cached number of submissions num_submissions = models.IntegerField(default=0) objects = ConferenceManager() def __repr__(self): return ( '<Conference(endpoint={self.endpoint!r}, active={self.active})>'. format(self=self)) @classmethod def get_by_endpoint(cls, endpoint, active): return cls.objects.get_by_endpoint(endpoint, active) class Meta: # custom permissions for use in the OSF Admin App permissions = (('view_conference', 'Can view conference details in the admin app.'), )
class NotificationDigest(ObjectIDMixin, BaseModel): user = models.ForeignKey('OSFUser', null=True, blank=True) timestamp = NonNaiveDateTimeField() send_type = models.CharField(max_length=50, db_index=True, validators=[ validate_subscription_type, ]) event = models.CharField(max_length=50) message = models.TextField() # TODO: Could this be a m2m with or without an order field? node_lineage = ArrayField(models.CharField(max_length=5))
class RegistrationBulkUploadJob(BaseModel): """Defines a database table that stores registration bulk upload jobs. """ # The hash of the CSV template payload payload_hash = models.CharField(blank=False, null=False, unique=True, max_length=255) # The status/state of the bulk upload state = models.IntegerField(choices=[(state, state.name) for state in JobState], default=JobState.PENDING) # The user / admin who started this bulk upload initiator = models.ForeignKey('OSFUser', blank=False, null=True, on_delete=models.CASCADE) # The registration provider this bulk upload targets provider = models.ForeignKey('RegistrationProvider', blank=False, null=True, on_delete=models.CASCADE) # The registration template this bulk upload uses schema = models.ForeignKey('RegistrationSchema', blank=False, null=True, on_delete=models.CASCADE) # The date when success / failure emails are sent after the creation of registrations in this upload has been done email_sent = NonNaiveDateTimeField(blank=True, null=True) @classmethod def create(cls, payload_hash, initiator, provider, schema, state=JobState.PENDING, email_sent=None): upload = cls( payload_hash=payload_hash, state=state, initiator=initiator, provider=provider, schema=schema, email_sent=email_sent, ) return upload
class Loggable(models.Model): last_logged = NonNaiveDateTimeField(db_index=True, null=True, blank=True, default=timezone.now) def add_log(self, action, params, auth, foreign_user=None, log_date=None, save=True, request=None): AbstractNode = apps.get_model('osf.AbstractNode') user = None if auth: user = auth.user elif request: user = request.user params['node'] = params.get('node') or params.get( 'project') or self._id original_node = self if self._id == params[ 'node'] else AbstractNode.load(params.get('node')) log = NodeLog(action=action, user=user, foreign_user=foreign_user, params=params, node=self, original_node=original_node) if log_date: log.date = log_date log.save() if self.logs.count() == 1: self.last_logged = log.date.replace(tzinfo=pytz.utc) else: self.last_logged = self.logs.first().date if save: self.save() if user and not self.is_collection: increment_user_activity_counters(user._primary_key, action, log.date.isoformat()) return log class Meta: abstract = True
class PrivateLink(ObjectIDMixin, BaseModel): date_created = NonNaiveDateTimeField(default=timezone.now) key = models.CharField(max_length=512, null=False, unique=True, blank=False) name = models.CharField(max_length=255, blank=True, null=True) is_deleted = models.BooleanField(default=False) anonymous = models.BooleanField(default=False) nodes = models.ManyToManyField('AbstractNode', related_name='private_links') creator = models.ForeignKey('OSFUser', null=True, blank=True, on_delete=models.SET_NULL) @property def node_ids(self): return self.nodes.filter(is_deleted=False).values_list('guids___id', flat=True) def node_scale(self, node): # node may be None if previous node's parent is deleted if node is None or node.parent_id not in self.node_ids: return -40 else: offset = 20 if node.parent_node is not None else 0 return offset + self.node_scale(node.parent_node) def to_json(self): return { 'id': self._id, 'date_created': iso8601format(self.date_created), 'key': self.key, 'name': sanitize.unescape_entities(self.name), 'creator': { 'fullname': self.creator.fullname, 'url': self.creator.profile_url }, 'nodes': [{ 'title': x.title, 'url': x.url, 'scale': str(self.node_scale(x)) + 'px', 'category': x.category } for x in self.nodes.filter(is_deleted=False)], 'anonymous': self.anonymous }
class FileLog(ObjectIDMixin, BaseModel): objects = IncludeManager() DATE_FORMAT = '%m/%d/%Y %H:%M UTC' # Log action constants -- NOTE: templates stored in log_templates.mako CREATED_FROM = 'created_from' CHECKED_IN = 'checked_in' CHECKED_OUT = 'checked_out' FILE_TAG_ADDED = 'file_tag_added' FILE_TAG_REMOVED = 'file_tag_removed' FILE_MOVED = 'addon_file_moved' FILE_COPIED = 'addon_file_copied' FILE_RENAMED = 'addon_file_renamed' FOLDER_CREATED = 'folder_created' FILE_ADDED = 'file_added' FILE_UPDATED = 'file_updated' FILE_REMOVED = 'file_removed' FILE_RESTORED = 'file_restored' PREPRINT_FILE_UPDATED = 'preprint_file_updated' actions = ([CHECKED_IN, CHECKED_OUT, FILE_TAG_REMOVED, FILE_TAG_ADDED, FILE_MOVED, FILE_COPIED, FOLDER_CREATED, FILE_ADDED, FILE_UPDATED, FILE_REMOVED, FILE_RESTORED, PREPRINT_FILE_UPDATED, ] + list(sum([ config.actions for config in apps.get_app_configs() if config.name.startswith('addons.') ], tuple()))) action_choices = [(action, action.upper()) for action in actions] project_id = models.CharField(max_length=255, null=True, blank=True, db_index=True) date = NonNaiveDateTimeField(db_index=True, null=True, blank=True, default=timezone.now) # TODO build action choices on the fly with the addon stuff action = models.CharField(max_length=255, db_index=True) # , choices=action_choices) user = models.ForeignKey('OSFUser', related_name='filelogs', db_index=True, null=True, blank=True) path = models.CharField(max_length=255, db_index=True, null=True) def __unicode__(self): return ('({self.action!r}, user={self.user!r},, file={self.file!r}, params={self.params!r}) ' 'with id {self.id!r}').format(self=self) class Meta: ordering = ['-date'] get_latest_by = 'date'
class BaseAddonSettings(ObjectIDMixin, BaseModel): is_deleted = models.BooleanField(default=False) deleted = NonNaiveDateTimeField(null=True, blank=True) class Meta: abstract = True @property def config(self): return self._meta.app_config @property def short_name(self): return self.config.short_name def delete(self, save=True): self.is_deleted = True self.deleted = timezone.now() self.on_delete() if save: self.save() def undelete(self, save=True): self.is_deleted = False self.deleted = None self.on_add() if save: self.save() def to_json(self, user): return { 'addon_short_name': self.config.short_name, 'addon_full_name': self.config.full_name, } ############# # Callbacks # ############# def on_add(self): """Called when the addon is added (or re-added) to the owner (User or Node).""" pass def on_delete(self): """Called when the addon is deleted from the owner (User or Node).""" pass
class Identifier(ObjectIDMixin, BaseModel): """A persistent identifier model for DOIs, ARKs, and the like.""" # object to which the identifier points object_id = models.PositiveIntegerField(null=True, blank=True) content_type = models.ForeignKey(ContentType, null=True, blank=True, on_delete=models.CASCADE) referent = GenericForeignKey() # category: e.g. 'ark', 'doi' category = models.CharField(max_length=20) # longest was 3, 8/19/2016 # value: e.g. 'FK424601' value = models.CharField(max_length=50) # longest was 21, 8/19/2016 deleted = NonNaiveDateTimeField(blank=True, null=True) class Meta: unique_together = ('object_id', 'content_type', 'category') def remove(self, save=True): """Mark an identifier as deleted, which excludes it from being returned in get_identifier""" self.deleted = timezone.now() if save: self.save()
class Retraction(EmailApprovableSanction): """ Retraction object for public registrations. Externally (specifically in user-facing language) retractions should be referred to as "Withdrawals", i.e. "Retract Registration" -> "Withdraw Registration", "Retracted" -> "Withdrawn", etc. """ DISPLAY_NAME = 'Retraction' SHORT_NAME = 'retraction' AUTHORIZER_NOTIFY_EMAIL_TEMPLATE = mails.PENDING_RETRACTION_ADMIN NON_AUTHORIZER_NOTIFY_EMAIL_TEMPLATE = mails.PENDING_RETRACTION_NON_ADMIN VIEW_URL_TEMPLATE = VIEW_PROJECT_URL_TEMPLATE APPROVE_URL_TEMPLATE = osf_settings.DOMAIN + 'token_action/{node_id}/?token={token}' REJECT_URL_TEMPLATE = osf_settings.DOMAIN + 'token_action/{node_id}/?token={token}' initiated_by = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.CASCADE) justification = models.CharField(max_length=2048, null=True, blank=True) date_retracted = NonNaiveDateTimeField(null=True, blank=True) def _get_registration(self): return self.registrations.first() def _view_url_context(self, user_id, node): registration = self.registrations.first() return { 'node_id': registration._id } def _approval_url_context(self, user_id): user_approval_state = self.approval_state.get(user_id, {}) approval_token = user_approval_state.get('approval_token') if approval_token: root_registration = self.registrations.first() node_id = user_approval_state.get('node_id', root_registration._id) return { 'node_id': node_id, 'token': approval_token, } def _rejection_url_context(self, user_id): user_approval_state = self.approval_state.get(user_id, {}) rejection_token = user_approval_state.get('rejection_token') if rejection_token: Registration = apps.get_model('osf.Registration') node_id = user_approval_state.get('node_id', None) registration = Registration.objects.select_related( 'registered_from' ).get( guids___id=node_id, guids___id__isnull=False ) if node_id else self.registrations.first() return { 'node_id': registration.registered_from._id, 'token': rejection_token, } def _email_template_context(self, user, node, is_authorizer=False, urls=None): urls = urls or self.stashed_urls.get(user._id, {}) registration_link = urls.get('view', self._view_url(user._id, node)) if is_authorizer: approval_link = urls.get('approve', '') disapproval_link = urls.get('reject', '') approval_time_span = osf_settings.RETRACTION_PENDING_TIME.days * 24 return { 'is_initiator': self.initiated_by == user, 'initiated_by': self.initiated_by.fullname, 'project_name': self.registrations.filter().values_list('title', flat=True).get(), 'registration_link': registration_link, 'approval_link': approval_link, 'disapproval_link': disapproval_link, 'approval_time_span': approval_time_span, } else: return { 'initiated_by': self.initiated_by.fullname, 'registration_link': registration_link, } def _on_reject(self, user): Registration = apps.get_model('osf.Registration') NodeLog = apps.get_model('osf.NodeLog') parent_registration = Registration.objects.get(retraction=self) parent_registration.registered_from.add_log( action=NodeLog.RETRACTION_CANCELLED, params={ 'node': parent_registration.registered_from._id, 'registration': parent_registration._id, 'retraction_id': self._id, }, auth=Auth(user), save=True, ) def _on_complete(self, user): Registration = apps.get_model('osf.Registration') NodeLog = apps.get_model('osf.NodeLog') self.date_retracted = timezone.now() self.save() parent_registration = Registration.objects.get(retraction=self) parent_registration.registered_from.add_log( action=NodeLog.RETRACTION_APPROVED, params={ 'node': parent_registration.registered_from._id, 'retraction_id': self._id, 'registration': parent_registration._id }, auth=Auth(self.initiated_by), ) # Remove any embargoes associated with the registration if parent_registration.embargo_end_date or parent_registration.is_pending_embargo: parent_registration.embargo.state = self.REJECTED parent_registration.registered_from.add_log( action=NodeLog.EMBARGO_CANCELLED, params={ 'node': parent_registration.registered_from._id, 'registration': parent_registration._id, 'embargo_id': parent_registration.embargo._id, }, auth=Auth(self.initiated_by), ) parent_registration.embargo.save() # Ensure retracted registration is public # Pass auth=None because the registration initiator may not be # an admin on components (component admins had the opportunity # to disapprove the retraction by this point) for node in parent_registration.node_and_primary_descendants(): node.set_privacy('public', auth=None, save=True, log=False) node.update_search() # force a save before sending data to share or retraction will not be updated self.save() project_tasks.update_node_share(parent_registration) def approve_retraction(self, user, token): self.approve(user, token) def disapprove_retraction(self, user, token): self.reject(user, token)
class Retraction(EmailApprovableSanction): """ Retraction object for public registrations. Externally (specifically in user-facing language) retractions should be referred to as "Withdrawals", i.e. "Retract Registration" -> "Withdraw Registration", "Retracted" -> "Withdrawn", etc. """ SANCTION_TYPE = SanctionTypes.RETRACTION DISPLAY_NAME = 'Retraction' SHORT_NAME = 'retraction' AUTHORIZER_NOTIFY_EMAIL_TEMPLATE = mails.PENDING_RETRACTION_ADMIN NON_AUTHORIZER_NOTIFY_EMAIL_TEMPLATE = mails.PENDING_RETRACTION_NON_ADMIN VIEW_URL_TEMPLATE = VIEW_PROJECT_URL_TEMPLATE APPROVE_URL_TEMPLATE = osf_settings.DOMAIN + 'token_action/{node_id}/?token={token}' REJECT_URL_TEMPLATE = osf_settings.DOMAIN + 'token_action/{node_id}/?token={token}' initiated_by = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.CASCADE) justification = models.CharField(max_length=2048, null=True, blank=True) date_retracted = NonNaiveDateTimeField(null=True, blank=True) def _get_registration(self): Registration = apps.get_model('osf.Registration') parent_registration = Registration.objects.get(retraction=self) return parent_registration def _view_url_context(self, user_id, node): registration = self.registrations.first() or node return {'node_id': registration._id} def _approval_url_context(self, user_id): user_approval_state = self.approval_state.get(user_id, {}) approval_token = user_approval_state.get('approval_token') if approval_token: root_registration = self.registrations.first() node_id = user_approval_state.get('node_id', root_registration._id) return { 'node_id': node_id, 'token': approval_token, } def _rejection_url_context(self, user_id): user_approval_state = self.approval_state.get(user_id, {}) rejection_token = user_approval_state.get('rejection_token') if rejection_token: Registration = apps.get_model('osf.Registration') node_id = user_approval_state.get('node_id', None) registration = Registration.objects.select_related( 'registered_from').get( guids___id=node_id, guids___id__isnull=False ) if node_id else self.registrations.first() return { 'node_id': registration.registered_from._id, 'token': rejection_token, } def _email_template_context(self, user, node, is_authorizer=False, urls=None): urls = urls or self.stashed_urls.get(user._id, {}) registration_link = urls.get('view', self._view_url(user._id, node)) approval_time_span = osf_settings.RETRACTION_PENDING_TIME.days * 24 if is_authorizer: approval_link = urls.get('approve', '') disapproval_link = urls.get('reject', '') return { 'is_initiator': self.initiated_by == user, 'is_moderated': self.is_moderated, 'reviewable': self._get_registration(), 'initiated_by': self.initiated_by.fullname, 'project_name': self.registrations.filter().values_list('title', flat=True).get(), 'registration_link': registration_link, 'approval_link': approval_link, 'disapproval_link': disapproval_link, 'approval_time_span': approval_time_span, } else: return { 'initiated_by': self.initiated_by.fullname, 'registration_link': registration_link, 'is_moderated': self.is_moderated, 'reviewable': self._get_registration(), 'approval_time_span': approval_time_span, } def _on_reject(self, event_data): user = event_data.kwargs.get('user') if user is None and event_data.args: user = event_data.args[0] NodeLog = apps.get_model('osf.NodeLog') parent_registration = self.target_registration parent_registration.registered_from.add_log( action=NodeLog.RETRACTION_CANCELLED, params={ 'node': parent_registration.registered_from._id, 'registration': parent_registration._id, 'retraction_id': self._id, }, auth=Auth(user), save=True, ) def _on_complete(self, event_data): super()._on_complete(event_data) self.date_retracted = timezone.now() registration = self.target_registration registration.withdraw() self.save() def approve_retraction(self, user, token): '''Test function''' self.approve(user=user, token=token) def disapprove_retraction(self, user, token): '''Test function''' self.reject(user=user, token=token)
class Sanction(ObjectIDMixin, BaseModel): """Sanction class is a generic way to track approval states""" # Neither approved not cancelled UNAPPROVED = ApprovalStates.UNAPPROVED.db_name # Has approval APPROVED = ApprovalStates.APPROVED.db_name # Rejected by at least one contributor REJECTED = ApprovalStates.REJECTED.db_name # Embargo has been completed COMPLETED = ApprovalStates.COMPLETED.db_name # Approved by admins but pending moderator approval/rejection PENDING_MODERATION = ApprovalStates.PENDING_MODERATION.db_name # Rejected by a moderator MODERATOR_REJECTED = ApprovalStates.MODERATOR_REJECTED.db_name SANCTION_TYPE = SanctionTypes.UNDEFINED DISPLAY_NAME = 'Sanction' # SHORT_NAME must correspond with the associated foreign field to query against, # e.g. Node.find_one(Q(sanction.SHORT_NAME, 'eq', sanction)) SHORT_NAME = 'sanction' ACTION_NOT_AUTHORIZED_MESSAGE = 'This user is not authorized to {ACTION} this {DISPLAY_NAME}' APPROVAL_INVALID_TOKEN_MESSAGE = 'Invalid approval token provided for this {DISPLAY_NAME}.' REJECTION_INVALID_TOKEN_MESSAGE = 'Invalid rejection token provided for this {DISPLAY_NAME}.' # Controls whether or not the Sanction needs unanimous approval or just a single approval ANY = 'any' UNANIMOUS = 'unanimous' mode = UNANIMOUS # Sanction subclasses must have an initiated_by field # initiated_by = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True) # Expanded: Dictionary field mapping admin IDs their approval status and relevant tokens: # { # 'b3k97': { # 'has_approved': False, # 'approval_token': 'Pew7wj1Puf7DENUPFPnXSwa1rf3xPN', # 'rejection_token': 'TwozClTFOic2PYxHDStby94bCQMwJy'} # } approval_state = DateTimeAwareJSONField(default=dict, blank=True) # Expiration date-- Sanctions in the UNAPPROVED state that are older than their end_date # are automatically made ACTIVE by a daily cron job # Use end_date=None for a non-expiring Sanction end_date = NonNaiveDateTimeField(null=True, blank=True, default=None) initiation_date = NonNaiveDateTimeField(default=timezone.now, null=True, blank=True) state = models.CharField(choices=ApprovalStates.char_field_choices(), default=UNAPPROVED, max_length=255) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.approvals_machine = ApprovalsMachine( model=self, state_property_name='approval_stage', active_state=self.approval_stage) def __str__(self): return f'<{self.__class__.__name__}(end_date={self.end_date!r}) with _id {self._id!r}>' @property def is_pending_approval(self): '''The sanction is awaiting admin approval.''' return self.approval_stage is ApprovalStates.UNAPPROVED @property def is_approved(self): '''The sanction has received all required admin and moderator approvals.''' return self.approval_stage is ApprovalStates.APPROVED @property def is_rejected(self): '''The sanction has been rejected by either an admin or a moderator.''' rejected_states = [ ApprovalStates.REJECTED, ApprovalStates.MODERATOR_REJECTED ] return self.approval_stage in rejected_states @property def is_moderated(self): return self.target_registration.is_moderated @property def approval_stage(self): return ApprovalStates.from_db_name(self.state) @approval_stage.setter def approval_stage(self, state): self.state = state.db_name @property def target_registration(self): return self._get_registration() @property def revisable(self): '''Controls state machine flow on a 'reject' trigger. True -> IN_PROGRESS False -> [MODERATOR_]REJECTED Sanctions do not represent a revisable entity, so return False ''' return False # The Sanction object will also inherit the following functions from the SanctionStateMachine: # # approve(self, user, token) # accept(self, user, token) # reject(self, user, token) # # Overriding these functions will divorce the offending Sanction class from that trigger's # functionality on the state machine. def _get_registration(self): """Get the Registration that is waiting on this sanction.""" raise NotImplementedError( 'Sanction subclasses must implement a #_get_registration method') def _on_approve(self, event_data): """Callback for individual admin approval of a sanction. Invoked by state machine as the last step of an 'approve' trigger :param EventData event_data: An EventData object from transitions.core contains information about the active state transition and arbitrary args and kwargs """ raise NotImplementedError( 'Sanction subclasses must implement an #_on_approve method') def _on_reject(self, event_data): """Callback for rejection of a Sanction. Invoked by state machine as the last step of a 'reject' trigger :param EventData event_data: An EventData object from transitions.core contains information about the active state transition and arbitrary args and kwargs """ raise NotImplementedError( 'Sanction subclasses must implement an #_on_reject method') def _on_complete(self, user): """Callback for when a Sanction is fully approved. Invoked by state machine as the last step of an 'accept' trigger :param EventData event_data: An EventData object from transitions.core contains information about the active state transition and arbitrary args and kwargs """ raise NotImplementedError( 'Sanction subclasses must implement an #_on_complete method') def forcibly_reject(self): self.state = Sanction.REJECTED def _save_transition(self, event_data): """Record the effects of a state transition in the database.""" self.save() new_state = event_data.transition.dest # No need to update registration state with no sanction state change if new_state is None: return user = event_data.kwargs.get('user') if user is None and event_data.args: user = event_data.args[0] comment = event_data.kwargs.get('comment', '') self.target_registration.update_moderation_state(initiated_by=user, comment=comment) class Meta: abstract = True
class PreprintService(DirtyFieldsMixin, GuidMixin, IdentifierMixin, ReviewableMixin, BaseModel): date_created = NonNaiveDateTimeField(auto_now_add=True) date_modified = NonNaiveDateTimeField(auto_now=True) provider = models.ForeignKey('osf.PreprintProvider', on_delete=models.SET_NULL, related_name='preprint_services', null=True, blank=True, db_index=True) node = models.ForeignKey('osf.AbstractNode', on_delete=models.SET_NULL, related_name='preprints', null=True, blank=True, db_index=True) is_published = models.BooleanField(default=False, db_index=True) date_published = NonNaiveDateTimeField(null=True, blank=True) original_publication_date = NonNaiveDateTimeField(null=True, blank=True) license = models.ForeignKey('osf.NodeLicenseRecord', on_delete=models.SET_NULL, null=True, blank=True) subjects = models.ManyToManyField(blank=True, to='osf.Subject', related_name='preprint_services') identifiers = GenericRelation(Identifier, related_query_name='preprintservices') class Meta: unique_together = ('node', 'provider') permissions = ( ('view_preprintservice', 'Can view preprint service details in the admin app.'), ) def __unicode__(self): return '{} preprint (guid={}) of {}'.format( 'published' if self.is_published else 'unpublished', self._id, self.node.__unicode__()) @property def verified_publishable(self): return self.is_published and self.node.is_preprint and not self.node.is_deleted @property def primary_file(self): if not self.node: return return self.node.preprint_file @property def article_doi(self): if not self.node: return return self.node.preprint_article_doi @property def preprint_doi(self): return self.get_identifier_value('doi') @property def is_preprint_orphan(self): if not self.node: return return self.node.is_preprint_orphan @cached_property def subject_hierarchy(self): return [ s.object_hierarchy for s in self.subjects.exclude(children__in=self.subjects.all()) ] @property def deep_url(self): # Required for GUID routing return '/preprints/{}/'.format(self._primary_key) @property def url(self): if (self.provider.domain_redirect_enabled and self.provider.domain) or self.provider._id == 'osf': return '/{}/'.format(self._id) return '/preprints/{}/{}/'.format(self.provider._id, self._id) @property def absolute_url(self): return urlparse.urljoin( self.provider.domain if self.provider.domain_redirect_enabled else settings.DOMAIN, self.url) @property def absolute_api_v2_url(self): path = '/preprints/{}/'.format(self._id) return api_v2_url(path) def has_permission(self, *args, **kwargs): return self.node.has_permission(*args, **kwargs) def get_subjects(self): ret = [] for subj_list in self.subject_hierarchy: subj_hierarchy = [] for subj in subj_list: if subj: subj_hierarchy += ({'id': subj._id, 'text': subj.text}, ) if subj_hierarchy: ret.append(subj_hierarchy) return ret def set_subjects(self, preprint_subjects, auth): if not self.node.has_permission(auth.user, ADMIN): raise PermissionsError( 'Only admins can change a preprint\'s subjects.') old_subjects = list(self.subjects.values_list('id', flat=True)) self.subjects.clear() for subj_list in preprint_subjects: subj_hierarchy = [] for s in subj_list: subj_hierarchy.append(s) if subj_hierarchy: validate_subject_hierarchy(subj_hierarchy) for s_id in subj_hierarchy: self.subjects.add(Subject.load(s_id)) self.save(old_subjects=old_subjects) def set_primary_file(self, preprint_file, auth, save=False): if not self.node.has_permission(auth.user, ADMIN): raise PermissionsError( 'Only admins can change a preprint\'s primary file.') if preprint_file.node != self.node or preprint_file.provider != 'osfstorage': raise ValueError( 'This file is not a valid primary file for this preprint.') existing_file = self.node.preprint_file self.node.preprint_file = preprint_file # only log if updating the preprint file, not adding for the first time if existing_file: self.node.add_log(action=NodeLog.PREPRINT_FILE_UPDATED, params={'preprint': self._id}, auth=auth, save=False) if save: self.save() self.node.save() def set_published(self, published, auth, save=False): if not self.node.has_permission(auth.user, ADMIN): raise PermissionsError('Only admins can publish a preprint.') if self.is_published and not published: raise ValueError('Cannot unpublish preprint.') self.is_published = published if published: if not (self.node.preprint_file and self.node.preprint_file.node == self.node): raise ValueError( 'Preprint node is not a valid preprint; cannot publish.') if not self.provider: raise ValueError( 'Preprint provider not specified; cannot publish.') if not self.subjects.exists(): raise ValueError( 'Preprint must have at least one subject to be published.') self.date_published = timezone.now() self.node._has_abandoned_preprint = False # In case this provider is ever set up to use a reviews workflow, put this preprint in a sensible state self.reviews_state = States.ACCEPTED.value self.date_last_transitioned = self.date_published self.node.add_log( action=NodeLog.PREPRINT_INITIATED, params={'preprint': self._id}, auth=auth, save=False, ) if not self.node.is_public: self.node.set_privacy(self.node.PUBLIC, auth=None, log=True) # This should be called after all fields for EZID metadta have been set enqueue_postcommit_task(get_and_set_preprint_identifiers, (), {'preprint': self}, celery=True) self._send_preprint_confirmation(auth) if save: self.node.save() self.save() def set_preprint_license(self, license_detail, auth, save=False): license_record, license_changed = set_license(self, license_detail, auth, node_type='preprint') if license_changed: self.node.add_log(action=NodeLog.PREPRINT_LICENSE_UPDATED, params={ 'preprint': self._id, 'new_license': license_record.node_license.name }, auth=auth, save=False) if save: self.save() def set_identifier_values(self, doi, ark, save=False): self.set_identifier_value('doi', doi) self.set_identifier_value('ark', ark) if save: self.save() def save(self, *args, **kwargs): first_save = not bool(self.pk) saved_fields = self.get_dirty_fields() or [] old_subjects = kwargs.pop('old_subjects', []) ret = super(PreprintService, self).save(*args, **kwargs) if (not first_save and 'is_published' in saved_fields) or self.is_published: enqueue_postcommit_task(on_preprint_updated, (self._id, ), {'old_subjects': old_subjects}, celery=True) return ret def _send_preprint_confirmation(self, auth): # Send creator confirmation email if self.provider._id == 'osf': email_template = getattr(mails, 'PREPRINT_CONFIRMATION_DEFAULT') else: email_template = getattr(mails, 'PREPRINT_CONFIRMATION_BRANDED')( self.provider) mails.send_mail(auth.user.username, email_template, user=auth.user, node=self.node, preprint=self)
class DraftRegistration(ObjectIDMixin, BaseModel): URL_TEMPLATE = settings.DOMAIN + 'project/{node_id}/drafts/{draft_id}' datetime_initiated = NonNaiveDateTimeField(auto_now_add=True) datetime_updated = NonNaiveDateTimeField(auto_now=True) deleted = NonNaiveDateTimeField(null=True, blank=True) # Original Node a draft registration is associated with branched_from = models.ForeignKey('Node', related_name='registered_draft', null=True, on_delete=models.CASCADE) initiator = models.ForeignKey('OSFUser', null=True, on_delete=models.CASCADE) provider = models.ForeignKey('RegistrationProvider', related_name='draft_registrations', null=True) # Dictionary field mapping question id to a question's comments and answer # { # <qid>: { # 'comments': [{ # 'user': { # 'id': <uid>, # 'name': <name> # }, # value: <value>, # lastModified: <datetime> # }], # 'value': <value> # } # } registration_metadata = DateTimeAwareJSONField(default=dict, blank=True) registration_schema = models.ForeignKey('RegistrationSchema', null=True, on_delete=models.CASCADE) registered_node = models.ForeignKey('Registration', null=True, blank=True, related_name='draft_registration', on_delete=models.CASCADE) approval = models.ForeignKey('DraftRegistrationApproval', null=True, blank=True, on_delete=models.CASCADE) # Dictionary field mapping extra fields defined in the RegistrationSchema.schema to their # values. Defaults should be provided in the schema (e.g. 'paymentSent': false), # and these values are added to the DraftRegistration # TODO: Use "FIELD_ALIASES"? _metaschema_flags = DateTimeAwareJSONField(default=dict, blank=True) notes = models.TextField(blank=True) def __repr__(self): return ('<DraftRegistration(branched_from={self.branched_from!r}) ' 'with id {self._id!r}>').format(self=self) # lazily set flags @property def flags(self): if not self._metaschema_flags: self._metaschema_flags = {} meta_schema = self.registration_schema if meta_schema: schema = meta_schema.schema flags = schema.get('flags', {}) dirty = False for flag, value in flags.items(): if flag not in self._metaschema_flags: self._metaschema_flags[flag] = value dirty = True if dirty: self.save() return self._metaschema_flags @flags.setter def flags(self, flags): self._metaschema_flags.update(flags) @property def url(self): return self.URL_TEMPLATE.format(node_id=self.branched_from._id, draft_id=self._id) @property def absolute_url(self): return urlparse.urljoin(settings.DOMAIN, self.url) @property def absolute_api_v2_url(self): node = self.branched_from path = '/nodes/{}/draft_registrations/{}/'.format(node._id, self._id) return api_v2_url(path) # used by django and DRF def get_absolute_url(self): return self.absolute_api_v2_url @property def requires_approval(self): return self.registration_schema.requires_approval @property def is_pending_review(self): return self.approval.is_pending_approval if ( self.requires_approval and self.approval) else False @property def is_approved(self): if self.requires_approval: if not self.approval: return bool(self.registered_node) else: return self.approval.is_approved else: return False @property def is_rejected(self): if self.requires_approval: if not self.approval: return False else: return self.approval.is_rejected else: return False @property def status_logs(self): """ List of logs associated with this node""" return self.logs.all().order_by('date') @classmethod def create_from_node(cls, node, user, schema, data=None, provider=None): if not provider: provider = RegistrationProvider.load('osf') draft = cls( initiator=user, branched_from=node, registration_schema=schema, registration_metadata=data or {}, provider=provider, ) draft.save() return draft def update_metadata(self, metadata): changes = [] # Prevent comments on approved drafts if not self.is_approved: for question_id, value in metadata.items(): old_value = self.registration_metadata.get(question_id) if old_value: old_comments = { comment['created']: comment for comment in old_value.get('comments', []) } new_comments = { comment['created']: comment for comment in value.get('comments', []) } old_comments.update(new_comments) metadata[question_id]['comments'] = sorted( old_comments.values(), key=lambda c: c['created']) if old_value.get('value') != value.get('value'): changes.append(question_id) else: changes.append(question_id) self.registration_metadata.update(metadata) return changes def submit_for_review(self, initiated_by, meta, save=False): approval = DraftRegistrationApproval(meta=meta) approval.save() self.approval = approval self.add_status_log(initiated_by, DraftRegistrationLog.SUBMITTED) if save: self.save() def register(self, auth, save=False, child_ids=None): node = self.branched_from # Create the registration register = node.register_node(schema=self.registration_schema, auth=auth, data=self.registration_metadata, child_ids=child_ids, provider=self.provider) self.registered_node = register self.add_status_log(auth.user, DraftRegistrationLog.REGISTERED) if save: self.save() return register def approve(self, user): self.approval.approve(user) self.refresh_from_db() self.add_status_log(user, DraftRegistrationLog.APPROVED) self.approval.save() def reject(self, user): self.approval.reject(user) self.add_status_log(user, DraftRegistrationLog.REJECTED) self.approval.save() def add_status_log(self, user, action): log = DraftRegistrationLog(action=action, user=user, draft=self) log.save() def validate_metadata(self, *args, **kwargs): """ Validates draft's metadata """ return self.registration_schema.validate_metadata(*args, **kwargs)
class Sanction(ObjectIDMixin, BaseModel): """Sanction class is a generic way to track approval states""" # Neither approved not cancelled UNAPPROVED = 'unapproved' # Has approval APPROVED = 'approved' # Rejected by at least one person REJECTED = 'rejected' # Embargo has been completed COMPLETED = 'completed' STATE_CHOICES = ((UNAPPROVED, UNAPPROVED.title()), (APPROVED, APPROVED.title()), (REJECTED, REJECTED.title()), (COMPLETED, COMPLETED.title()),) DISPLAY_NAME = 'Sanction' # SHORT_NAME must correspond with the associated foreign field to query against, # e.g. Node.find_one(Q(sanction.SHORT_NAME, 'eq', sanction)) SHORT_NAME = 'sanction' APPROVAL_NOT_AUTHORIZED_MESSAGE = 'This user is not authorized to approve this {DISPLAY_NAME}' APPROVAL_INVALID_TOKEN_MESSAGE = 'Invalid approval token provided for this {DISPLAY_NAME}.' REJECTION_NOT_AUTHORIZED_MESSAEGE = 'This user is not authorized to reject this {DISPLAY_NAME}' REJECTION_INVALID_TOKEN_MESSAGE = 'Invalid rejection token provided for this {DISPLAY_NAME}.' # Controls whether or not the Sanction needs unanimous approval or just a single approval ANY = 'any' UNANIMOUS = 'unanimous' mode = UNANIMOUS # Sanction subclasses must have an initiated_by field # initiated_by = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True) # Expanded: Dictionary field mapping admin IDs their approval status and relevant tokens: # { # 'b3k97': { # 'has_approved': False, # 'approval_token': 'Pew7wj1Puf7DENUPFPnXSwa1rf3xPN', # 'rejection_token': 'TwozClTFOic2PYxHDStby94bCQMwJy'} # } approval_state = DateTimeAwareJSONField(default=dict, blank=True) # Expiration date-- Sanctions in the UNAPPROVED state that are older than their end_date # are automatically made ACTIVE by a daily cron job # Use end_date=None for a non-expiring Sanction end_date = NonNaiveDateTimeField(null=True, blank=True, default=None) initiation_date = NonNaiveDateTimeField(default=timezone.now, null=True, blank=True) state = models.CharField(choices=STATE_CHOICES, default=UNAPPROVED, max_length=255) def __repr__(self): return '<{self.__class__.__name__}(end_date={self.end_date!r}) with _id {self._id!r}>'.format( self=self) @property def is_pending_approval(self): return self.state == Sanction.UNAPPROVED @property def is_approved(self): return self.state == Sanction.APPROVED @property def is_rejected(self): return self.state == Sanction.REJECTED def approve(self, user): raise NotImplementedError('Sanction subclasses must implement an approve method.') def reject(self, user): raise NotImplementedError('Sanction subclasses must implement an approve method.') def _on_reject(self, user): """Callback for rejection of a Sanction :param User user: """ raise NotImplementedError( 'Sanction subclasses must implement an #_on_reject method') def _on_complete(self, user): """Callback for when a Sanction has approval and enters the ACTIVE state :param User user: """ raise NotImplementedError( 'Sanction subclasses must implement an #_on_complete method') def forcibly_reject(self): self.state = Sanction.REJECTED class Meta: abstract = True
class Registration(AbstractNode): WRITABLE_WHITELIST = [ 'article_doi', 'description', 'is_public', 'node_license', ] article_doi = models.CharField(max_length=128, validators=[validate_doi], null=True, blank=True) provider = models.ForeignKey('RegistrationProvider', related_name='registrations', null=True) registered_date = NonNaiveDateTimeField(db_index=True, null=True, blank=True) registered_user = models.ForeignKey(OSFUser, related_name='related_to', on_delete=models.SET_NULL, null=True, blank=True) registered_schema = models.ManyToManyField(RegistrationSchema) registered_meta = DateTimeAwareJSONField(default=dict, blank=True) registered_from = models.ForeignKey('self', related_name='registrations', on_delete=models.SET_NULL, null=True, blank=True) # Sanctions registration_approval = models.ForeignKey('RegistrationApproval', related_name='registrations', null=True, blank=True, on_delete=models.SET_NULL) retraction = models.ForeignKey('Retraction', related_name='registrations', null=True, blank=True, on_delete=models.SET_NULL) embargo = models.ForeignKey('Embargo', related_name='registrations', null=True, blank=True, on_delete=models.SET_NULL) embargo_termination_approval = models.ForeignKey( 'EmbargoTerminationApproval', related_name='registrations', null=True, blank=True, on_delete=models.SET_NULL) @staticmethod def find_failed_registrations(): expired_if_before = timezone.now() - settings.ARCHIVE_TIMEOUT_TIMEDELTA node_id_list = ArchiveJob.objects.filter( sent=False, datetime_initiated__lt=expired_if_before, status=ARCHIVER_INITIATED).values_list('dst_node', flat=True) root_nodes_id = AbstractNode.objects.filter( id__in=node_id_list).values_list('root', flat=True).distinct() stuck_regs = AbstractNode.objects.filter(id__in=root_nodes_id, is_deleted=False) return stuck_regs @property def registered_schema_id(self): if self.registered_schema.exists(): return self.registered_schema.first()._id return None @property def is_registration(self): """For v1 compat.""" return True @property def is_stuck_registration(self): return self in self.find_failed_registrations() @property def is_collection(self): """For v1 compat.""" return False @property def archive_job(self): return self.archive_jobs.first() if self.archive_jobs.count() else None @property def sanction(self): root = self._dirty_root sanction = (root.embargo_termination_approval or root.retraction or root.embargo or root.registration_approval) if sanction: return sanction else: return None @property def is_registration_approved(self): root = self._dirty_root if root.registration_approval is None: return False return root.registration_approval.is_approved @property def is_pending_embargo(self): root = self._dirty_root if root.embargo is None: return False return root.embargo.is_pending_approval @property def is_pending_embargo_for_existing_registration(self): """ Returns True if Node has an Embargo pending approval for an existing registrations. This is used specifically to ensure registrations pre-dating the Embargo feature do not get deleted if their respective Embargo request is rejected. """ root = self._dirty_root if root.embargo is None: return False return root.embargo.pending_registration @property def is_retracted(self): root = self._dirty_root if root.retraction is None: return False return root.retraction.is_approved @property def is_pending_registration(self): root = self._dirty_root if root.registration_approval is None: return False return root.registration_approval.is_pending_approval @property def is_pending_retraction(self): root = self._dirty_root if root.retraction is None: return False return root.retraction.is_pending_approval @property def is_pending_embargo_termination(self): root = self._dirty_root if root.embargo_termination_approval is None: return False return root.embargo_termination_approval.is_pending_approval @property def is_embargoed(self): """A Node is embargoed if: - it has an associated Embargo record - that record has been approved - the node is not public (embargo not yet lifted) """ root = self._dirty_root if root.is_public or root.embargo is None: return False return root.embargo.is_approved @property def embargo_end_date(self): root = self._dirty_root if root.embargo is None: return False return root.embargo.embargo_end_date @property def archiving(self): job = self.archive_job return job and not job.done and not job.archive_tree_finished() @property def _dirty_root(self): """Equivalent to `self.root`, but don't let Django fetch a clean copy when `self == self.root`. Use when it's important to reflect unsaved state rather than database state. """ if self.id == self.root_id: return self return self.root def date_withdrawn(self): return getattr(self.root.retraction, 'date_retracted', None) @property def withdrawal_justification(self): return getattr(self.root.retraction, 'justification', None) def _initiate_embargo(self, user, end_date, for_existing_registration=False, notify_initiator_on_complete=False): """Initiates the retraction process for a registration :param user: User who initiated the retraction :param end_date: Date when the registration should be made public """ end_date_midnight = datetime.datetime.combine( end_date, datetime.datetime.min.time()).replace(tzinfo=end_date.tzinfo) self.embargo = Embargo.objects.create( initiated_by=user, end_date=end_date_midnight, for_existing_registration=for_existing_registration, notify_initiator_on_complete=notify_initiator_on_complete) self.save() # Set foreign field reference Node.embargo admins = self.get_admin_contributors_recursive(unique_users=True) for (admin, node) in admins: self.embargo.add_authorizer(admin, node) self.embargo.save() # Save embargo's approval_state return self.embargo def embargo_registration(self, user, end_date, for_existing_registration=False, notify_initiator_on_complete=False): """Enter registration into an embargo period at end of which, it will be made public :param user: User initiating the embargo :param end_date: Date when the registration should be made public :raises: NodeStateError if Node is not a registration :raises: PermissionsError if user is not an admin for the Node :raises: ValidationError if end_date is not within time constraints """ if not self.has_permission(user, 'admin'): raise PermissionsError('Only admins may embargo a registration') if not self._is_embargo_date_valid(end_date): if (end_date - timezone.now()) >= settings.EMBARGO_END_DATE_MIN: raise ValidationError( 'Registrations can only be embargoed for up to four years.' ) raise ValidationError( 'Embargo end date must be at least three days in the future.') embargo = self._initiate_embargo( user, end_date, for_existing_registration=for_existing_registration, notify_initiator_on_complete=notify_initiator_on_complete) self.registered_from.add_log( action=NodeLog.EMBARGO_INITIATED, params={ 'node': self.registered_from._id, 'registration': self._id, 'embargo_id': embargo._id, }, auth=Auth(user), save=True, ) if self.is_public: self.set_privacy('private', Auth(user)) def request_embargo_termination(self, auth): """Initiates an EmbargoTerminationApproval to lift this Embargoed Registration's embargo early.""" if not self.is_embargoed: raise NodeStateError('This node is not under active embargo') if not self.root == self: raise NodeStateError( 'Only the root of an embargoed registration can request termination' ) approval = EmbargoTerminationApproval( initiated_by=auth.user, embargoed_registration=self, ) admins = [ admin for admin in self.root.get_admin_contributors_recursive( unique_users=True) ] for (admin, node) in admins: approval.add_authorizer(admin, node=node) approval.save() approval.ask(admins) self.embargo_termination_approval = approval self.save() return approval def terminate_embargo(self, auth): """Handles the actual early termination of an Embargoed registration. Adds a log to the registered_from Node. """ if not self.is_embargoed: raise NodeStateError('This node is not under active embargo') self.registered_from.add_log(action=NodeLog.EMBARGO_TERMINATED, params={ 'project': self._id, 'node': self.registered_from._id, 'registration': self._id, }, auth=None, save=True) self.embargo.mark_as_completed() for node in self.node_and_primary_descendants(): node.set_privacy(self.PUBLIC, auth=None, log=False, save=True) return True def _initiate_retraction(self, user, justification=None): """Initiates the retraction process for a registration :param user: User who initiated the retraction :param justification: Justification, if given, for retraction """ self.retraction = Retraction.objects.create( initiated_by=user, justification=justification or None, # make empty strings None state=Retraction.UNAPPROVED) self.save() admins = self.get_admin_contributors_recursive(unique_users=True) for (admin, node) in admins: self.retraction.add_authorizer(admin, node) self.retraction.save() # Save retraction approval state return self.retraction def retract_registration(self, user, justification=None, save=True): """Retract public registration. Instantiate new Retraction object and associate it with the respective registration. """ if not self.is_public and not (self.embargo_end_date or self.is_pending_embargo): raise NodeStateError( 'Only public or embargoed registrations may be withdrawn.') if self.root_id != self.id: raise NodeStateError( 'Withdrawal of non-parent registrations is not permitted.') retraction = self._initiate_retraction(user, justification) self.registered_from.add_log( action=NodeLog.RETRACTION_INITIATED, params={ 'node': self.registered_from._id, 'registration': self._id, 'retraction_id': retraction._id, }, auth=Auth(user), ) self.retraction = retraction if save: self.save() return retraction def copy_unclaimed_records(self): """Copies unclaimed_records to unregistered contributors from the registered_from node""" registered_from_id = self.registered_from._id for contributor in self.contributors.filter(is_registered=False): record = contributor.unclaimed_records.get(registered_from_id) if record: contributor.unclaimed_records[self._id] = record contributor.save() def delete_registration_tree(self, save=False): logger.debug('Marking registration {} as deleted'.format(self._id)) self.is_deleted = True for draft_registration in DraftRegistration.objects.filter( registered_node=self): # Allow draft registration to be submitted if draft_registration.approval: draft_registration.approval = None draft_registration.save() if not getattr(self.embargo, 'for_existing_registration', False): self.registered_from = None if save: self.save() self.update_search() for child in self.nodes_primary: child.delete_registration_tree(save=save) def add_tag(self, tag, auth=None, save=True, log=True, system=False): if self.retraction is None: super(Registration, self).add_tag(tag, auth, save, log, system) else: raise NodeStateError('Cannot add tags to withdrawn registrations.') def add_tags(self, tags, auth=None, save=True, log=True, system=False): if self.retraction is None: super(Registration, self).add_tags(tags, auth, save, log, system) else: raise NodeStateError('Cannot add tags to withdrawn registrations.') def remove_tag(self, tag, auth, save=True): if self.retraction is None: super(Registration, self).remove_tag(tag, auth, save) else: raise NodeStateError( 'Cannot remove tags of withdrawn registrations.') def remove_tags(self, tags, auth, save=True): if self.retraction is None: super(Registration, self).remove_tags(tags, auth, save) else: raise NodeStateError( 'Cannot remove tags of withdrawn registrations.') class Meta: # custom permissions for use in the OSF Admin App permissions = (('view_registration', 'Can view registration details'), )
class NodeLog(ObjectIDMixin, BaseModel): FIELD_ALIASES = { # TODO: Find a better way 'node': 'node__guids___id', 'user': '******', 'original_node': 'original_node__guids___id' } objects = IncludeManager() DATE_FORMAT = '%m/%d/%Y %H:%M UTC' # Log action constants -- NOTE: templates stored in log_templates.mako CREATED_FROM = 'created_from' PROJECT_CREATED = 'project_created' # Nodes created as part of the registration process PROJECT_CREATED_FROM_DRAFT_REG = 'project_created_from_draft_reg' PROJECT_REGISTERED = 'project_registered' PROJECT_DELETED = 'project_deleted' NODE_CREATED = 'node_created' NODE_FORKED = 'node_forked' NODE_REMOVED = 'node_removed' NODE_ACCESS_REQUESTS_ENABLED = 'node_access_requests_enabled' NODE_ACCESS_REQUESTS_DISABLED = 'node_access_requests_disabled' POINTER_CREATED = NODE_LINK_CREATED = 'pointer_created' POINTER_FORKED = NODE_LINK_FORKED = 'pointer_forked' POINTER_REMOVED = NODE_LINK_REMOVED = 'pointer_removed' WIKI_UPDATED = 'wiki_updated' WIKI_DELETED = 'wiki_deleted' WIKI_RENAMED = 'wiki_renamed' MADE_WIKI_PUBLIC = 'made_wiki_public' MADE_WIKI_PRIVATE = 'made_wiki_private' CONTRIB_ADDED = 'contributor_added' CONTRIB_REMOVED = 'contributor_removed' CONTRIB_REORDERED = 'contributors_reordered' CHECKED_IN = 'checked_in' CHECKED_OUT = 'checked_out' PERMISSIONS_UPDATED = 'permissions_updated' MADE_PRIVATE = 'made_private' MADE_PUBLIC = 'made_public' TAG_ADDED = 'tag_added' TAG_REMOVED = 'tag_removed' FILE_TAG_ADDED = 'file_tag_added' FILE_TAG_REMOVED = 'file_tag_removed' FILE_METADATA_UPDATED = 'file_metadata_updated' EDITED_TITLE = 'edit_title' EDITED_DESCRIPTION = 'edit_description' CHANGED_LICENSE = 'license_changed' UPDATED_FIELDS = 'updated_fields' FILE_MOVED = 'addon_file_moved' FILE_COPIED = 'addon_file_copied' FILE_RENAMED = 'addon_file_renamed' FOLDER_CREATED = 'folder_created' FILE_ADDED = 'file_added' FILE_UPDATED = 'file_updated' FILE_REMOVED = 'file_removed' FILE_RESTORED = 'file_restored' CATEGORY_UPDATED = 'category_updated' ARTICLE_DOI_UPDATED = 'article_doi_updated' ADDON_ADDED = 'addon_added' ADDON_REMOVED = 'addon_removed' COMMENT_ADDED = 'comment_added' COMMENT_REMOVED = 'comment_removed' COMMENT_UPDATED = 'comment_updated' COMMENT_RESTORED = 'comment_restored' CUSTOM_CITATION_ADDED = 'custom_citation_added' CUSTOM_CITATION_EDITED = 'custom_citation_edited' CUSTOM_CITATION_REMOVED = 'custom_citation_removed' MADE_CONTRIBUTOR_VISIBLE = 'made_contributor_visible' MADE_CONTRIBUTOR_INVISIBLE = 'made_contributor_invisible' EXTERNAL_IDS_ADDED = 'external_ids_added' EMBARGO_APPROVED = 'embargo_approved' EMBARGO_CANCELLED = 'embargo_cancelled' EMBARGO_COMPLETED = 'embargo_completed' EMBARGO_INITIATED = 'embargo_initiated' EMBARGO_TERMINATED = 'embargo_terminated' GROUP_ADDED = 'group_added' GROUP_UPDATED = 'group_updated' GROUP_REMOVED = 'group_removed' RETRACTION_APPROVED = 'retraction_approved' RETRACTION_CANCELLED = 'retraction_cancelled' RETRACTION_INITIATED = 'retraction_initiated' EXTERNAL_REGISTRATION_CREATED = 'external_registration_created' EXTERNAL_REGISTRATION_IMPORTED = 'external_registration_imported' REGISTRATION_APPROVAL_CANCELLED = 'registration_cancelled' REGISTRATION_APPROVAL_INITIATED = 'registration_initiated' REGISTRATION_APPROVAL_APPROVED = 'registration_approved' PREREG_REGISTRATION_INITIATED = 'prereg_registration_initiated' AFFILIATED_INSTITUTION_ADDED = 'affiliated_institution_added' AFFILIATED_INSTITUTION_REMOVED = 'affiliated_institution_removed' PREPRINT_INITIATED = 'preprint_initiated' PREPRINT_FILE_UPDATED = 'preprint_file_updated' PREPRINT_LICENSE_UPDATED = 'preprint_license_updated' SUBJECTS_UPDATED = 'subjects_updated' VIEW_ONLY_LINK_ADDED = 'view_only_link_added' VIEW_ONLY_LINK_REMOVED = 'view_only_link_removed' CONFIRM_HAM = 'confirm_ham' FLAG_SPAM = 'flag_spam' CONFIRM_SPAM = 'confirm_spam' actions = ([ CHECKED_IN, CHECKED_OUT, FILE_TAG_REMOVED, FILE_TAG_ADDED, CREATED_FROM, PROJECT_CREATED, PROJECT_REGISTERED, PROJECT_DELETED, NODE_CREATED, NODE_FORKED, NODE_REMOVED, NODE_ACCESS_REQUESTS_ENABLED, NODE_ACCESS_REQUESTS_DISABLED, NODE_LINK_CREATED, NODE_LINK_FORKED, NODE_LINK_REMOVED, WIKI_UPDATED, WIKI_DELETED, WIKI_RENAMED, MADE_WIKI_PUBLIC, MADE_WIKI_PRIVATE, CONTRIB_ADDED, CONTRIB_REMOVED, CONTRIB_REORDERED, PERMISSIONS_UPDATED, MADE_PRIVATE, MADE_PUBLIC, TAG_ADDED, TAG_REMOVED, EDITED_TITLE, EDITED_DESCRIPTION, UPDATED_FIELDS, FILE_MOVED, FILE_COPIED, FILE_METADATA_UPDATED, FOLDER_CREATED, FILE_ADDED, FILE_UPDATED, FILE_REMOVED, FILE_RESTORED, ADDON_ADDED, ADDON_REMOVED, COMMENT_ADDED, COMMENT_REMOVED, COMMENT_UPDATED, COMMENT_RESTORED, MADE_CONTRIBUTOR_VISIBLE, CONFIRM_HAM, FLAG_SPAM, CONFIRM_SPAM, MADE_CONTRIBUTOR_INVISIBLE, EXTERNAL_IDS_ADDED, EMBARGO_APPROVED, EMBARGO_TERMINATED, EMBARGO_CANCELLED, EMBARGO_COMPLETED, EMBARGO_INITIATED, RETRACTION_APPROVED, RETRACTION_CANCELLED, RETRACTION_INITIATED, EXTERNAL_REGISTRATION_CREATED, EXTERNAL_REGISTRATION_IMPORTED, REGISTRATION_APPROVAL_CANCELLED, REGISTRATION_APPROVAL_INITIATED, REGISTRATION_APPROVAL_APPROVED, PREREG_REGISTRATION_INITIATED, PROJECT_CREATED_FROM_DRAFT_REG, GROUP_ADDED, GROUP_UPDATED, GROUP_REMOVED, AFFILIATED_INSTITUTION_ADDED, AFFILIATED_INSTITUTION_REMOVED, PREPRINT_INITIATED, PREPRINT_FILE_UPDATED, PREPRINT_LICENSE_UPDATED, VIEW_ONLY_LINK_ADDED, VIEW_ONLY_LINK_REMOVED ] + list( sum([ config.actions for config in apps.get_app_configs() if config.name.startswith('addons.') ], tuple()))) action_choices = [(action, action.upper()) for action in actions] date = NonNaiveDateTimeField(db_index=True, null=True, blank=True, default=timezone.now) # TODO build action choices on the fly with the addon stuff action = models.CharField(max_length=255, db_index=True) # , choices=action_choices) params = DateTimeAwareJSONField(default=dict) should_hide = models.BooleanField(default=False) user = models.ForeignKey('OSFUser', related_name='logs', db_index=True, null=True, blank=True, on_delete=models.CASCADE) foreign_user = models.CharField(max_length=255, null=True, blank=True) node = models.ForeignKey('AbstractNode', related_name='logs', db_index=True, null=True, blank=True, on_delete=models.CASCADE) original_node = models.ForeignKey('AbstractNode', db_index=True, null=True, blank=True, on_delete=models.CASCADE) def __repr__(self): return ( '({self.action!r}, user={self.user!r},, node={self.node!r}, params={self.params!r}) ' 'with id {self.id!r}').format(self=self) class Meta: ordering = ['-date'] get_latest_by = 'date' @property def absolute_api_v2_url(self): path = '/logs/{}/'.format(self._id) return api_v2_url(path) def get_absolute_url(self): return self.absolute_api_v2_url @property def absolute_url(self): return self.absolute_api_v2_url def _natural_key(self): return self._id
class Comment(GuidMixin, SpamMixin, CommentableMixin, BaseModel): __guid_min_length__ = 12 OVERVIEW = 'node' FILES = 'files' WIKI = 'wiki' user = models.ForeignKey('OSFUser', null=True) # the node that the comment belongs to node = models.ForeignKey('AbstractNode', null=True) # The file or project overview page that the comment is for root_target = models.ForeignKey(Guid, on_delete=models.SET_NULL, related_name='comments', null=True, blank=True) # the direct 'parent' of the comment (e.g. the target of a comment reply is another comment) target = models.ForeignKey(Guid, on_delete=models.SET_NULL, related_name='child_comments', null=True, blank=True) date_created = NonNaiveDateTimeField(auto_now_add=True) date_modified = NonNaiveDateTimeField(auto_now=True) modified = models.BooleanField(default=False) is_deleted = models.BooleanField(default=False) # The type of root_target: node/files page = models.CharField(max_length=255, blank=True) content = models.TextField(validators=[ validators.CommentMaxLength(settings.COMMENT_MAXLENGTH), validators.string_required ]) # The mentioned users # TODO This should be made into an M2M STAT ever_mentioned = ArrayField(models.CharField(max_length=10, blank=True), default=list, blank=True) @property def url(self): return '/{}/'.format(self._id) @property def absolute_api_v2_url(self): path = '/comments/{}/'.format(self._id) return api_v2_url(path) @property def target_type(self): """The object "type" used in the OSF v2 API.""" return 'comments' @property def root_target_page(self): """The page type associated with the object/Comment.root_target.""" return None def belongs_to_node(self, node_id): """Check whether the comment is attached to the specified node.""" return self.node._id == node_id # used by django and DRF def get_absolute_url(self): return self.absolute_api_v2_url def get_comment_page_url(self): if isinstance(self.root_target.referent, Node): return self.node.absolute_url return settings.DOMAIN + str(self.root_target._id) + '/' def get_content(self, auth): """ Returns the comment content if the user is allowed to see it. Deleted comments can only be viewed by the user who created the comment.""" if not auth and not self.node.is_public: raise PermissionsError if self.is_deleted and ((not auth or auth.user.is_anonymous()) or (auth and not auth.user.is_anonymous() and self.user._id != auth.user._id)): return None return self.content def get_comment_page_title(self): if self.page == Comment.FILES: return self.root_target.referent.name elif self.page == Comment.WIKI: return self.root_target.referent.page_name return '' def get_comment_page_type(self): if self.page == Comment.FILES: return 'file' elif self.page == Comment.WIKI: return 'wiki' return self.node.project_or_component @classmethod def find_n_unread(cls, user, node, page, root_id=None): if node.is_contributor(user): if page == Comment.OVERVIEW: view_timestamp = user.get_node_comment_timestamps( target_id=node._id) root_target = Guid.load(node._id) elif page == Comment.FILES or page == Comment.WIKI: view_timestamp = user.get_node_comment_timestamps( target_id=root_id) root_target = Guid.load(root_id) else: raise ValueError('Invalid page') if not view_timestamp.tzinfo: view_timestamp = view_timestamp.replace(tzinfo=pytz.utc) return cls.objects.filter( Q(node=node) & ~Q(user=user) & Q(is_deleted=False) & (Q(date_created__gt=view_timestamp) | Q(date_modified__gt=view_timestamp)) & Q(root_target=root_target)).count() return 0 @classmethod def create(cls, auth, **kwargs): comment = cls(**kwargs) if not comment.node.can_comment(auth): raise PermissionsError( '{0!r} does not have permission to comment on this node'. format(auth.user)) log_dict = { 'project': comment.node.parent_id, 'node': comment.node._id, 'user': comment.user._id, 'comment': comment._id, } if isinstance(comment.target.referent, Comment): comment.root_target = comment.target.referent.root_target else: comment.root_target = comment.target page = getattr(comment.root_target.referent, 'root_target_page', None) if not page: raise ValueError('Invalid root target.') comment.page = page log_dict.update( comment.root_target.referent.get_extra_log_params(comment)) if comment.content: new_mentions = get_valid_mentioned_users_guids( comment, comment.node.contributors) if new_mentions: project_signals.mention_added.send(comment, new_mentions=new_mentions, auth=auth) comment.ever_mentioned.extend(new_mentions) comment.save() comment.node.add_log( NodeLog.COMMENT_ADDED, log_dict, auth=auth, save=False, ) comment.node.save() project_signals.comment_added.send(comment, auth=auth) return comment def edit(self, content, auth, save=False): if not self.node.can_comment(auth) or self.user._id != auth.user._id: raise PermissionsError( '{0!r} does not have permission to edit this comment'.format( auth.user)) log_dict = { 'project': self.node.parent_id, 'node': self.node._id, 'user': self.user._id, 'comment': self._id, } log_dict.update(self.root_target.referent.get_extra_log_params(self)) self.content = content self.modified = True self.date_modified = timezone.now() new_mentions = get_valid_mentioned_users_guids(self, self.node.contributors) if save: if new_mentions: project_signals.mention_added.send(self, new_mentions=new_mentions, auth=auth) self.ever_mentioned.extend(new_mentions) self.save() self.node.add_log( NodeLog.COMMENT_UPDATED, log_dict, auth=auth, save=False, ) self.node.save() def delete(self, auth, save=False): if not self.node.can_comment(auth) or self.user._id != auth.user._id: raise PermissionsError( '{0!r} does not have permission to comment on this node'. format(auth.user)) log_dict = { 'project': self.node.parent_id, 'node': self.node._id, 'user': self.user._id, 'comment': self._id, } self.is_deleted = True log_dict.update(self.root_target.referent.get_extra_log_params(self)) self.date_modified = timezone.now() if save: self.save() self.node.add_log( NodeLog.COMMENT_REMOVED, log_dict, auth=auth, save=False, ) self.node.save() def undelete(self, auth, save=False): if not self.node.can_comment(auth) or self.user._id != auth.user._id: raise PermissionsError( '{0!r} does not have permission to comment on this node'. format(auth.user)) self.is_deleted = False log_dict = { 'project': self.node.parent_id, 'node': self.node._id, 'user': self.user._id, 'comment': self._id, } log_dict.update(self.root_target.referent.get_extra_log_params(self)) self.date_modified = timezone.now() if save: self.save() self.node.add_log( NodeLog.COMMENT_RESTORED, log_dict, auth=auth, save=False, ) self.node.save()