class ChronosSubmission(BaseModel): publication_id = models.TextField(null=False, blank=False, unique=True) journal = models.ForeignKey(ChronosJournal, null=False, blank=False) preprint = models.ForeignKey('osf.Preprint', null=False, blank=False) submitter = models.ForeignKey('osf.OSFUser', null=False, blank=False) status = models.IntegerField(null=True, blank=True, default=None, choices=ChronosSubmissionStatus.choices()) raw_response = DateTimeAwareJSONField(null=False, blank=False) submission_url = models.TextField(null=False, blank=False) class Meta: unique_together = [('preprint', 'journal')] def __repr__(self): return '<{}(journal={!r}, preprint={!r}, submitter={!r}, status={!r})>'.format( self.__class__.__name__, self.journal, self.preprint, self.submitter, self.status, )
class DraftRegistrationApproval(Sanction): mode = Sanction.ANY # Since draft registrations that require approval are not immediately registered, # meta stores registration_choice and embargo_end_date (when applicable) meta = DateTimeAwareJSONField(default=dict, blank=True) def _send_rejection_email(self, user, draft): raise NotImplementedError('TODO: add a generic email template for registration approvals') def approve(self, user): if not user.has_perm('osf.administer_prereg'): raise PermissionsError('This user does not have permission to approve this draft.') self.state = Sanction.APPROVED self._on_complete(user) def reject(self, user): if not user.has_perm('osf.administer_prereg'): raise PermissionsError('This user does not have permission to approve this draft.') self.state = Sanction.REJECTED self._on_reject(user) def _on_complete(self, user): DraftRegistration = apps.get_model('osf.DraftRegistration') draft = DraftRegistration.objects.get(approval=self) initiator = draft.initiator.merged_by or draft.initiator auth = Auth(initiator) registration = draft.register( auth=auth, save=True ) registration_choice = self.meta['registration_choice'] if registration_choice == 'immediate': sanction = functools.partial(registration.require_approval, initiator) elif registration_choice == 'embargo': embargo_end_date = parse_date(self.meta.get('embargo_end_date')) if not embargo_end_date.tzinfo: embargo_end_date = embargo_end_date.replace(tzinfo=pytz.UTC) sanction = functools.partial( registration.embargo_registration, initiator, embargo_end_date ) else: raise ValueError("'registration_choice' must be either 'embargo' or 'immediate'") sanction(notify_initiator_on_complete=True) def _on_reject(self, user, *args, **kwargs): DraftRegistration = apps.get_model('osf.DraftRegistration') # clear out previous registration options self.meta = {} self.save() draft = DraftRegistration.objects.get(approval=self) initiator = draft.initiator.merged_by or draft.initiator self._send_rejection_email(initiator, draft)
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 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 Region(models.Model): _id = models.CharField(max_length=255, db_index=True) name = models.CharField(max_length=200) waterbutler_credentials = EncryptedJSONField(default=dict) waterbutler_url = models.URLField(default=website_settings.WATERBUTLER_URL) waterbutler_settings = DateTimeAwareJSONField(default=dict) class Meta: unique_together = ('_id', 'name')
class UserActivityCounter(BaseModel): primary_identifier_name = '_id' _id = models.CharField(max_length=5, null=False, blank=False, db_index=True, unique=True) # 5 in prod action = DateTimeAwareJSONField(default=dict) date = DateTimeAwareJSONField(default=dict) total = models.PositiveIntegerField(default=0) @classmethod def get_total_activity_count(cls, user_id): try: return cls.objects.get(_id=user_id).total except cls.DoesNotExist: return 0 @classmethod def increment(cls, user_id, action, date_string): date = parser.parse(date_string).strftime('%Y/%m/%d') with transaction.atomic(): # select_for_update locks the row but only inside a transaction uac, created = cls.objects.select_for_update().get_or_create( _id=user_id) if uac.total > 0: uac.total += 1 else: uac.total = 1 if action in uac.action: uac.action[action]['total'] += 1 if date in uac.action[action]['date']: uac.action[action]['date'][date] += 1 else: uac.action[action]['date'][date] = 1 else: uac.action[action] = dict(total=1, date={date: 1}) if date in uac.date: uac.date[date]['total'] += 1 else: uac.date[date] = dict(total=1) uac.save() return True
class Session(ObjectIDMixin, BaseModel): 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 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 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 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 ChronosJournal(BaseModel): name = models.TextField(null=False, blank=False) title = models.TextField(null=False, blank=False) journal_id = models.TextField(unique=True, null=False, blank=False) raw_response = DateTimeAwareJSONField(null=False, blank=False) primary_identifier_name = 'journal_id' def __repr__(self): return '<{}({} - {})>'.format( self.__class__.__name__, self.name, self.title, )
class OSFGroupLog(ObjectIDMixin, BaseModel): objects = IncludeManager() DATE_FORMAT = '%m/%d/%Y %H:%M UTC' GROUP_CREATED = 'group_created' MEMBER_ADDED = 'member_added' MANAGER_ADDED = 'manager_added' MEMBER_REMOVED = 'member_removed' ROLE_UPDATED = 'role_updated' EDITED_NAME = 'edit_name' NODE_CONNECTED = 'node_connected' NODE_PERMS_UPDATED = 'node_permissions_updated' NODE_DISCONNECTED = 'node_disconnected' actions = ([GROUP_CREATED, MEMBER_ADDED, MANAGER_ADDED, MEMBER_REMOVED, ROLE_UPDATED, EDITED_NAME, NODE_CONNECTED, NODE_PERMS_UPDATED, NODE_DISCONNECTED]) action_choices = [(action, action.upper()) for action in actions] action = models.CharField(max_length=255, db_index=True) params = DateTimeAwareJSONField(default=dict) should_hide = models.BooleanField(default=False) user = models.ForeignKey('OSFUser', related_name='group_logs', db_index=True, null=True, blank=True, on_delete=models.CASCADE) group = models.ForeignKey('OSFGroup', related_name='logs', db_index=True, null=True, blank=True, on_delete=models.CASCADE) def __unicode__(self): return ('({self.action!r}, user={self.user!r}, group={self.group!r}, params={self.params!r}) ' 'with id {self.id!r}').format(self=self) class Meta: ordering = ['-created'] get_latest_by = 'created' @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
class AbstractSchema(ObjectIDMixin, BaseModel): name = models.CharField(max_length=255) schema = DateTimeAwareJSONField(default=dict) category = models.CharField(max_length=255, null=True, blank=True) active = models.BooleanField(default=True) # Version of the schema to use (e.g. if questions, responses change) schema_version = models.IntegerField() class Meta: abstract = True unique_together = ('name', 'schema_version') def __unicode__(self): return '(name={}, schema_version={}, id={})'.format( self.name, self.schema_version, self.id)
class FileMetadataRecord(ObjectIDMixin, BaseModel): metadata = DateTimeAwareJSONField(default=dict, blank=True) file = models.ForeignKey(OsfStorageFile, related_name='records', on_delete=models.SET_NULL, null=True) schema = models.ForeignKey(FileMetadataSchema, related_name='records', on_delete=models.SET_NULL, null=True) class Meta: unique_together = ('file', 'schema') def __unicode__(self): return '(file={}, schema={}, _id={})'.format(self.file.name, self.schema, self._id) @property def absolute_api_v2_url(self): path = '/files/{}/metadata_records/{}/'.format(self.file._id, self._id) return api_v2_url(path) @property def serializer(self): return serializer_registry[self.schema._id] def serialize(self, format='json'): return self.serializer.serialize(self, format) def validate_metadata(self, proposed_metadata): return jsonschema.validate(proposed_metadata, from_json(self.serializer.osf_schema)) def update(self, proposed_metadata, user=None): auth = Auth(user) if user else None if auth and self.file.target.has_permission(user, osf_permissions.WRITE): self.validate_metadata(proposed_metadata) self.metadata = proposed_metadata self.save() target = self.file.target target.add_log( action=target.log_class.FILE_METADATA_UPDATED, params={ 'path': self.file.materialized_path, }, auth=auth, ) else: raise PermissionsError('You must have write access for this file to update its metadata.')
class AbstractSchema(ObjectIDMixin, BaseModel): name = models.CharField(max_length=255) schema = DateTimeAwareJSONField(default=dict) category = models.CharField(max_length=255, null=True, blank=True) active = models.BooleanField(default=True) # whether or not the schema accepts submissions visible = models.BooleanField(default=True) # whether or not the schema should be visible in the API and registries search # Version of the schema to use (e.g. if questions, responses change) schema_version = models.IntegerField() objects = AbstractSchemaManager() class Meta: abstract = True unique_together = ('name', 'schema_version') def __unicode__(self): return '(name={}, schema_version={}, id={})'.format(self.name, self.schema_version, self.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') #: 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 ArchiveTarget(ObjectIDMixin, BaseModel): """Stores the results of archiving a single addon """ # addon_short_name of target addon name = models.CharField(max_length=2048) status = models.CharField(max_length=40, default=ARCHIVER_INITIATED) # <dict> representation of a website.archiver.AggregateStatResult # Format: { # 'target_id': <str>, # 'target_name': <str>, # 'targets': <list>(StatResult | AggregateStatResult), # 'num_files': <int>, # 'disk_usage': <float>, # } stat_result = DateTimeAwareJSONField(default=dict, blank=True) errors = ArrayField(models.TextField(), default=list, blank=True) def __repr__(self): return '<{0}(_id={1}, name={2}, status={3})>'.format( self.__class__.__name__, self._id, self.name, self.status)
class Region(models.Model): _id = models.CharField(max_length=255, db_index=True) name = models.CharField(max_length=200) waterbutler_credentials = EncryptedJSONField(default=dict) waterbutler_url = models.URLField(default=website_settings.WATERBUTLER_URL) mfr_url = models.URLField(default=website_settings.MFR_SERVER_URL) waterbutler_settings = DateTimeAwareJSONField(default=dict) def __unicode__(self): return '{}'.format(self.name) def get_absolute_url(self): return '{}regions/{}'.format(self.absolute_api_v2_url, self._id) @property def absolute_api_v2_url(self): path = '/regions/{}/'.format(self._id) return api_v2_url(path) class Meta: unique_together = ('_id', 'name')
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 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 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 DraftRegistrationApproval(Sanction): SANCTION_TYPE = SanctionTypes.DRAFT_REGISTRATION_APPROVAL mode = Sanction.ANY # Since draft registrations that require approval are not immediately registered, # meta stores registration_choice and embargo_end_date (when applicable) meta = DateTimeAwareJSONField(default=dict, blank=True) def _send_rejection_email(self, user, draft): mails.send_mail( to_addr=user.username, mail=mails.DRAFT_REGISTRATION_REJECTED, user=user, osf_url=osf_settings.DOMAIN, provider=draft.provider, can_change_preferences=False, ) def approve(self, user): self.state = Sanction.APPROVED self._on_complete(user) def reject(self, user): self.state = Sanction.REJECTED self._on_reject(user) def _on_complete(self, user): DraftRegistration = apps.get_model('osf.DraftRegistration') draft = DraftRegistration.objects.get(approval=self) initiator = draft.initiator.merged_by or draft.initiator auth = Auth(initiator) registration = draft.register(auth=auth, save=True) registration_choice = self.meta['registration_choice'] if registration_choice == 'immediate': sanction = functools.partial(registration.require_approval, initiator) elif registration_choice == 'embargo': embargo_end_date = parse_date(self.meta.get('embargo_end_date')) if not embargo_end_date.tzinfo: embargo_end_date = embargo_end_date.replace(tzinfo=pytz.UTC) sanction = functools.partial(registration.embargo_registration, initiator, embargo_end_date) else: raise ValueError( "'registration_choice' must be either 'embargo' or 'immediate'" ) sanction(notify_initiator_on_complete=True) def _on_reject(self, user, *args, **kwargs): DraftRegistration = apps.get_model('osf.DraftRegistration') # clear out previous registration options self.meta = {} self.save() draft = DraftRegistration.objects.get(approval=self) initiator = draft.initiator.merged_by or draft.initiator self._send_rejection_email(initiator, draft)
class BaseFileNode(TypedModel, CommentableMixin, OptionalGuidMixin, Taggable, ObjectIDMixin, BaseModel): """Base class for all provider-specific file models and the trashed file model. This class should generally not be used or created manually. Use the provider-specific subclasses instead. WARNING: Be careful when using ``.filter``, ``.exclude``, etc. on this model. The default queryset for will NOT filter out TrashedFileNodes by default. Also, calling ``.load`` may return a `TrashedFileNode`. Use the ``BaseFileNode.active`` manager when you want to filter out TrashedFileNodes. """ version_identifier = 'revision' # For backwards compatibility FOLDER, FILE, ANY = 0, 1, 2 # The User that has this file "checked out" # Should only be used for OsfStorage checkout = models.ForeignKey('osf.OSFUser', blank=True, null=True) # The last time the touch method was called on this FileNode last_touched = NonNaiveDateTimeField(null=True, blank=True) # A list of dictionaries sorted by the 'modified' key # The raw output of the metadata request deduped by etag # Add regardless it can be pinned to a version or not _history = DateTimeAwareJSONField(default=list, blank=True) # A concrete version of a FileNode, must have an identifier versions = models.ManyToManyField('FileVersion') node = models.ForeignKey('osf.AbstractNode', blank=True, null=True, related_name='files') parent = models.ForeignKey('self', blank=True, null=True, default=None, related_name='_children') copied_from = models.ForeignKey('self', blank=True, null=True, default=None, related_name='copy_of') provider = models.CharField(max_length=25, blank=False, null=False, db_index=True) name = models.TextField(blank=True, null=True) _path = models.TextField(blank=True, null=True) # 1950 on prod _materialized_path = models.TextField(blank=True, null=True) # 482 on staging is_deleted = False deleted_on = NonNaiveDateTimeField(blank=True, null=True) deleted_by = models.ForeignKey('osf.OSFUser', related_name='files_deleted_by', null=True, blank=True) objects = BaseFileNodeManager() active = ActiveFileNodeManager() _base_manager = BaseFileNodeManager() @property def history(self): return self._history @history.setter def history(self, value): setattr(self, '_history', value) @property def is_file(self): # TODO split is file logic into subclasses return isinstance(self, (File, TrashedFile)) @property def path(self): return self._path @path.setter def path(self, value): self._path = value @property def materialized_path(self): return self._materialized_path @materialized_path.setter def materialized_path(self, val): self._materialized_path = val @property def deep_url(self): """The url that this filenodes guid should resolve to. Implemented here so that subclasses may override it or path. See OsfStorage or PathFollowingNode. """ return self.node.web_url_for('addon_view_or_download_file', provider=self.provider, path=self.path.strip('/')) @property def absolute_api_v2_url(self): path = '/files/{}/'.format(self._id) return api_v2_url(path) # For Comment API compatibility @property def target_type(self): """The object "type" used in the OSF v2 API.""" return 'files' @property def root_target_page(self): """The comment page type associated with StoredFileNodes.""" return 'files' @property def stored_object(self): """ DEPRECATED: Returns self after logging. :return: """ logger.warn('BaseFileNode.stored_object is deprecated.') return self @stored_object.setter def stored_object(self, value): raise DeprecatedException('BaseFileNode.stored_object is deprecated.') @classmethod def create(cls, **kwargs): kwargs.update(provider=cls._provider) return cls(**kwargs) @classmethod def get_or_create(cls, node, path): try: obj = cls.objects.get(node=node, _path='/' + path.lstrip('/')) except cls.DoesNotExist: obj = cls(node=node, _path='/' + path.lstrip('/')) return obj @classmethod def get_file_guids(cls, materialized_path, provider, node): guids = [] materialized_path = '/' + materialized_path.lstrip('/') if materialized_path.endswith('/'): # it's a folder folder_children = cls.find( Q('provider', 'eq', provider) & Q('node', 'eq', node) & Q('_materialized_path', 'startswith', materialized_path)) for item in folder_children: if item.kind == 'file': guid = item.get_guid() if guid: guids.append(guid._id) else: # it's a file try: file_obj = cls.find_one( Q('node', 'eq', node) & Q('_materialized_path', 'eq', materialized_path)) except NoResultsFound: return guids guid = file_obj.get_guid() if guid: guids.append(guid._id) return guids def to_storage(self): storage = super(BaseFileNode, self).to_storage() if 'trashed' not in self.type.lower(): for key in tuple(storage.keys()): if 'deleted' in key: storage.pop(key) return storage @classmethod def files_checked_out(cls, user): """ :param user: The user with checked out files :return: A queryset of all FileNodes checked out by user """ return cls.find(Q('checkout', 'eq', user)) @classmethod def resolve_class(cls, provider, type_integer): type_mapping = {0: Folder, 1: File, 2: None} type_cls = type_mapping[type_integer] for subclass in BaseFileNode.__subclasses__(): if type_cls: for subsubclass in subclass.__subclasses__(): if issubclass( subsubclass, type_cls) and subsubclass._provider == provider: return subsubclass else: if subclass._provider == provider: return subclass raise UnableToResolveFileClass( 'Could not resolve class for {} and {}'.format(provider, type_cls)) def _resolve_class(self, type_cls): for subclass in BaseFileNode.__subclasses__(): if type_cls: for subsubclass in subclass.__subclasses__(): if issubclass(subsubclass, type_cls ) and subsubclass._provider == self.provider: return subsubclass else: if subclass._provider == self.provider: return subclass def get_version(self, revision, required=False): """Find a version with identifier revision :returns: FileVersion or None :raises: VersionNotFoundError if required is True """ try: return self.versions.get(identifier=revision) except ObjectDoesNotExist: if required: raise VersionNotFoundError(revision) return None def generate_waterbutler_url(self, **kwargs): return waterbutler_api_url_for(self.node._id, self.provider, self.path, **kwargs) def update_version_metadata(self, location, metadata): try: self.versions.get(location=location).update_metadata(metadata) return except ObjectDoesNotExist: raise VersionNotFoundError(location) def touch(self, auth_header, revision=None, **kwargs): """The bread and butter of File, collects metadata about self and creates versions and updates self when required. If revisions is None the created version is NOT and should NOT be saved as there is no identifing information to tell if it needs to be updated or not. Hits Waterbutler's metadata endpoint and saves the returned data. If a file cannot be rendered IE figshare private files a tuple of the FileVersion and renderable HTML will be returned. >>>isinstance(file_node.touch(), tuple) # This file cannot be rendered :param str or None auth_header: If truthy it will set as the Authorization header :returns: None if the file is not found otherwise FileVersion or (version, Error HTML) """ # Resvolve primary key on first touch self.save() # For backwards compatibility revision = revision or kwargs.get(self.version_identifier) version = self.get_version(revision) # Versions do not change. No need to refetch what we already know if version is not None: return version headers = {} if auth_header: headers['Authorization'] = auth_header resp = requests.get( self.generate_waterbutler_url(revision=revision, meta=True, _internal=True, **kwargs), headers=headers, ) if resp.status_code != 200: logger.warning('Unable to find {} got status code {}'.format( self, resp.status_code)) return None return self.update(revision, resp.json()['data']['attributes']) # TODO Switch back to head requests # return self.update(revision, json.loads(resp.headers['x-waterbutler-metadata'])) def get_download_count(self, version=None): """Pull the download count from the pagecounter collection Limit to version if specified. Currently only useful for OsfStorage """ parts = ['download', self.node._id, self._id] if version is not None: parts.append(version) page = ':'.join([format(part) for part in parts]) _, count = get_basic_counters(page) return count or 0 def copy_under(self, destination_parent, name=None): return utils.copy_files(self, destination_parent.node, destination_parent, name=name) def move_under(self, destination_parent, name=None): self.name = name or self.name self.parent = destination_parent.stored_object self._update_node(save=True) # Trust _update_node to save us return self def belongs_to_node(self, node_id): """Check whether the file is attached to the specified node.""" return self.node._id == node_id def get_extra_log_params(self, comment): return { 'file': { 'name': self.name, 'url': comment.get_comment_page_url() } } # used by django and DRF def get_absolute_url(self): return self.absolute_api_v2_url def _repoint_guids(self, updated): logger.warn('BaseFileNode._repoint_guids is deprecated.') def _update_node(self, recursive=True, save=True): if self.parent is not None: self.node = self.parent.node if save: self.save() if recursive and not self.is_file: for child in self.children: child._update_node(save=save) def wrapped(self): """Wrap self in a FileNode subclass """ logger.warn('Wrapped is deprecated.') return self # TODO: Remove unused parent param def delete(self, user=None, parent=None, save=True, deleted_on=None): """ Recast a Folder to TrashedFolder, set fields related to deleting, and recast children. :param user: :param parent: :param save: :param deleted_on: :return: """ self.deleted_by = user self.deleted_on = deleted_on = deleted_on or timezone.now() if not self.is_file: self.recast(TrashedFolder._typedmodels_type) for child in BaseFileNode.objects.filter(parent=self.id).exclude( type__in=TrashedFileNode._typedmodels_subtypes): child.delete(user=user, save=save, deleted_on=deleted_on) else: self.recast(TrashedFile._typedmodels_type) if save: self.save() return self def _serialize(self, **kwargs): return { 'id': self._id, 'path': self.path, 'name': self.name, 'kind': self.kind, } def save(self, *args, **kwargs): if hasattr(self._meta.model, '_provider') and self._meta.model._provider is not None: self.provider = self._meta.model._provider super(BaseFileNode, self).save(*args, **kwargs) def __repr__(self): return '<{}(name={!r}, node={!r})>'.format(self.__class__.__name__, self.name, self.node)
class FileVersion(ObjectIDMixin, BaseModel): """A version of an OsfStorageFileNode. contains information about where the file is located, hashes and datetimes """ creator = models.ForeignKey('OSFUser', null=True, blank=True) identifier = models.CharField(max_length=100, blank=False, null=False) # max length on staging was 51 # Date version record was created. This is the date displayed to the user. date_created = NonNaiveDateTimeField(auto_now_add=True) size = models.BigIntegerField(default=-1, blank=True, null=True) content_type = models.CharField(max_length=100, blank=True, null=True) # was 24 on staging # Date file modified on third-party backend. Not displayed to user, since # this date may be earlier than the date of upload if the file already # exists on the backend date_modified = NonNaiveDateTimeField(null=True, blank=True) metadata = DateTimeAwareJSONField(blank=True, default=dict) location = DateTimeAwareJSONField(default=None, blank=True, null=True, validators=[validate_location]) includable_objects = IncludeQuerySet.as_manager() @property def location_hash(self): return self.location['object'] @property def archive(self): return self.metadata.get('archive') def is_duplicate(self, other): return self.location_hash == other.location_hash def update_metadata(self, metadata, save=True): self.metadata.update(metadata) # metadata has no defined structure so only attempt to set attributes # If its are not in this callback it'll be in the next self.size = self.metadata.get('size', self.size) self.content_type = self.metadata.get('contentType', self.content_type) if self.metadata.get('modified'): self.date_modified = parse_date(self.metadata['modified'], ignoretz=False) if save: self.save() def _find_matching_archive(self, save=True): """Find another version with the same sha256 as this file. If found copy its vault name and glacier id, no need to create additional backups. returns True if found otherwise false """ if 'sha256' not in self.metadata: return False # Dont bother searching for nothing if 'vault' in self.metadata and 'archive' in self.metadata: # Shouldn't ever happen, but we already have an archive return True # We've found ourself qs = self.__class__.find( Q('_id', 'ne', self._id) & Q('metadata.sha256', 'eq', self.metadata['sha256']) & Q('metadata.archive', 'ne', None) & Q('metadata.vault', 'ne', None)).limit(1) if qs.count() < 1: return False other = qs[0] try: self.metadata['vault'] = other.metadata['vault'] self.metadata['archive'] = other.metadata['archive'] except KeyError: return False if save: self.save() return True class Meta: ordering = ('date_created', )
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 EmailApprovableSanction(TokenApprovableSanction): AUTHORIZER_NOTIFY_EMAIL_TEMPLATE = None NON_AUTHORIZER_NOTIFY_EMAIL_TEMPLATE = None VIEW_URL_TEMPLATE = '' APPROVE_URL_TEMPLATE = '' REJECT_URL_TEMPLATE = '' # A flag to conditionally run a callback on complete notify_initiator_on_complete = models.BooleanField(default=False) # Store a persistant copy of urls for use when needed outside of a request context. # This field gets automagically updated whenever models approval_state is modified # and the model is saved # { # 'abcde': { # 'approve': [APPROVAL_URL], # 'reject': [REJECT_URL], # } # } stashed_urls = DateTimeAwareJSONField(default=dict, blank=True) @property def should_suppress_emails(self): return self._get_registration().external_registration @staticmethod def _format_or_empty(template, context): if context: return template.format(**context) return '' def _view_url(self, user_id, node): return self._format_or_empty(self.VIEW_URL_TEMPLATE, self._view_url_context(user_id, node)) def _view_url_context(self, user_id, node): return None def _approval_url(self, user_id): return self._format_or_empty(self.APPROVE_URL_TEMPLATE, self._approval_url_context(user_id)) def _approval_url_context(self, user_id): return None def _rejection_url(self, user_id): return self._format_or_empty(self.REJECT_URL_TEMPLATE, self._rejection_url_context(user_id)) def _rejection_url_context(self, user_id): return None def _send_approval_request_email(self, user, template, context): mails.send_mail(user.username, template, user=user, can_change_preferences=False, **context) def _email_template_context(self, user, node, is_authorizer=False): return {} def _notify_authorizer(self, authorizer, node): context = self._email_template_context(authorizer, node, is_authorizer=True) if self.AUTHORIZER_NOTIFY_EMAIL_TEMPLATE: self._send_approval_request_email( authorizer, self.AUTHORIZER_NOTIFY_EMAIL_TEMPLATE, context) else: raise NotImplementedError() def _notify_non_authorizer(self, user, node): context = self._email_template_context(user, node) if self.NON_AUTHORIZER_NOTIFY_EMAIL_TEMPLATE: self._send_approval_request_email( user, self.NON_AUTHORIZER_NOTIFY_EMAIL_TEMPLATE, context) else: raise NotImplementedError def ask(self, group): """ :param list group: List of (user, node) tuples containing contributors to notify about the sanction. """ if self.should_suppress_emails: return for contrib, node in group: if contrib._id in self.approval_state: self._notify_authorizer(contrib, node) else: self._notify_non_authorizer(contrib, node) def add_authorizer(self, user, node, **kwargs): super(EmailApprovableSanction, self).add_authorizer(user, node, **kwargs) self.stashed_urls[user._id] = { 'view': self._view_url(user._id, node), 'approve': self._approval_url(user._id), 'reject': self._rejection_url(user._id) } self.save() def _notify_initiator(self): raise NotImplementedError def _on_complete(self, event_data): if self.notify_initiator_on_complete and not self.should_suppress_emails: self._notify_initiator() class Meta: abstract = True
class SpamMixin(models.Model): """Mixin to add to objects that can be marked as spam. """ class Meta: abstract = True # # Node fields that trigger an update to search on save # SPAM_UPDATE_FIELDS = { # 'spam_status', # } spam_status = models.IntegerField(default=SpamStatus.UNKNOWN, null=True, blank=True, db_index=True) spam_pro_tip = models.CharField(default=None, null=True, blank=True, max_length=200) # Data representing the original spam indication # - author: author name # - author_email: email of the author # - content: data flagged # - headers: request headers # - Remote-Addr: ip address from request # - User-Agent: user agent from request # - Referer: referrer header from request (typo +1, rtd) spam_data = DateTimeAwareJSONField(default=dict, blank=True) date_last_reported = NonNaiveDateTimeField(default=None, null=True, blank=True, db_index=True) # Reports is a dict of reports keyed on reporting user # Each report is a dictionary including: # - date: date reported # - retracted: if a report has been retracted # - category: What type of spam does the reporter believe this is # - text: Comment on the comment reports = DateTimeAwareJSONField( default=dict, blank=True, validators=[_validate_reports] ) def flag_spam(self): # If ham and unedited then tell user that they should read it again if self.spam_status == SpamStatus.UNKNOWN: self.spam_status = SpamStatus.FLAGGED def remove_flag(self, save=False): if self.spam_status != SpamStatus.FLAGGED: return for report in self.reports.values(): if not report.get('retracted', True): return self.spam_status = SpamStatus.UNKNOWN if save: self.save() @property def is_spam(self): return self.spam_status == SpamStatus.SPAM @property def is_spammy(self): return self.spam_status in [SpamStatus.FLAGGED, SpamStatus.SPAM] def report_abuse(self, user, save=False, **kwargs): """Report object is spam or other abuse of OSF :param user: User submitting report :param save: Save changes :param kwargs: Should include category and message :raises ValueError: if user is reporting self """ if user == self.user: raise ValueError('User cannot report self.') self.flag_spam() date = timezone.now() report = {'date': date, 'retracted': False} report.update(kwargs) if 'text' not in report: report['text'] = None self.reports[user._id] = report self.date_last_reported = report['date'] if save: self.save() def retract_report(self, user, save=False): """Retract last report by user Only marks the last report as retracted because there could be history in how the object is edited that requires a user to flag or retract even if object is marked as HAM. :param user: User retracting :param save: Save changes """ if user._id in self.reports: if not self.reports[user._id]['retracted']: self.reports[user._id]['retracted'] = True self.remove_flag() else: raise ValueError('User has not reported this content') if save: self.save() def confirm_ham(self, save=False): # not all mixins will implement check spam pre-req, only submit ham when it was incorrectly flagged if ( settings.SPAM_CHECK_ENABLED and self.spam_data and self.spam_status in [SpamStatus.FLAGGED, SpamStatus.SPAM] ): client = _get_client() client.submit_ham( user_ip=self.spam_data['headers']['Remote-Addr'], user_agent=self.spam_data['headers'].get('User-Agent'), referrer=self.spam_data['headers'].get('Referer'), comment_content=self.spam_data['content'], comment_author=self.spam_data['author'], comment_author_email=self.spam_data['author_email'], ) logger.info('confirm_ham update sent') self.spam_status = SpamStatus.HAM if save: self.save() def confirm_spam(self, save=False): # not all mixins will implement check spam pre-req, only submit spam when it was incorrectly flagged if ( settings.SPAM_CHECK_ENABLED and self.spam_data and self.spam_status in [SpamStatus.UNKNOWN, SpamStatus.HAM] ): client = _get_client() client.submit_spam( user_ip=self.spam_data['headers']['Remote-Addr'], user_agent=self.spam_data['headers'].get('User-Agent'), referrer=self.spam_data['headers'].get('Referer'), comment_content=self.spam_data['content'], comment_author=self.spam_data['author'], comment_author_email=self.spam_data['author_email'], ) logger.info('confirm_spam update sent') self.spam_status = SpamStatus.SPAM if save: self.save() @abc.abstractmethod def check_spam(self, user, saved_fields, request_headers, save=False): """Must return is_spam""" pass def do_check_spam(self, author, author_email, content, request_headers, update=True): if self.spam_status == SpamStatus.HAM: return False if self.is_spammy: return True client = _get_client() remote_addr = request_headers['Remote-Addr'] user_agent = request_headers.get('User-Agent') referer = request_headers.get('Referer') is_spam, pro_tip = client.check_comment( user_ip=remote_addr, user_agent=user_agent, referrer=referer, comment_content=content, comment_author=author, comment_author_email=author_email ) if update: self.spam_pro_tip = pro_tip self.spam_data['headers'] = { 'Remote-Addr': remote_addr, 'User-Agent': user_agent, 'Referer': referer, } self.spam_data['content'] = content self.spam_data['author'] = author self.spam_data['author_email'] = author_email if is_spam: self.flag_spam() return is_spam
class BaseOAuthUserSettings(BaseUserSettings): # Keeps track of what nodes have been given permission to use external # accounts belonging to the user. oauth_grants = DateTimeAwareJSONField(default=dict, blank=True) # example: # { # '<Node._id>': { # '<ExternalAccount._id>': { # <metadata> # }, # } # } # # metadata here is the specific to each addon. # The existence of this property is used to determine whether or not # an addon instance is an "OAuth addon" in # AddonModelMixin.get_oauth_addons(). oauth_provider = None serializer = serializer.OAuthAddonSerializer class Meta: abstract = True @property def has_auth(self): return self.external_accounts.exists() @property def external_accounts(self): """The user's list of ``ExternalAccount`` instances for this provider""" return self.owner.external_accounts.filter( provider=self.oauth_provider.short_name) def delete(self, save=True): for account in self.external_accounts.filter( provider=self.config.short_name): self.revoke_oauth_access(account, save=False) super(BaseOAuthUserSettings, self).delete(save=save) def grant_oauth_access(self, node, external_account, metadata=None): """Give a node permission to use an ``ExternalAccount`` instance.""" # ensure the user owns the external_account if not self.owner.external_accounts.filter( id=external_account.id).exists(): raise PermissionsError() metadata = metadata or {} # create an entry for the node, if necessary if node._id not in self.oauth_grants: self.oauth_grants[node._id] = {} # create an entry for the external account on the node, if necessary if external_account._id not in self.oauth_grants[node._id]: self.oauth_grants[node._id][external_account._id] = {} # update the metadata with the supplied values for key, value in metadata.items(): self.oauth_grants[node._id][external_account._id][key] = value self.save() @must_be_logged_in def revoke_oauth_access(self, external_account, auth, save=True): """Revoke all access to an ``ExternalAccount``. TODO: This should accept node and metadata params in the future, to allow fine-grained revocation of grants. That's not yet been needed, so it's not yet been implemented. """ for node in self.get_nodes_with_oauth_grants(external_account): try: node.get_addon(external_account.provider, deleted=True).deauthorize(auth=auth) except AttributeError: # No associated addon settings despite oauth grant pass if external_account.osfuser_set.count() == 1 and \ external_account.osfuser_set.filter(id=auth.user.id).exists(): # Only this user is using the account, so revoke remote access as well. self.revoke_remote_oauth_access(external_account) for key in self.oauth_grants: self.oauth_grants[key].pop(external_account._id, None) if save: self.save() def revoke_remote_oauth_access(self, external_account): """ Makes outgoing request to remove the remote oauth grant stored by third-party provider. Individual addons must override this method, as it is addon-specific behavior. Not all addon providers support this through their API, but those that do should also handle the case where this is called with an external_account with invalid credentials, to prevent a user from being unable to disconnect an account. """ pass def verify_oauth_access(self, node, external_account, metadata=None): """Verify that access has been previously granted. If metadata is not provided, this checks only if the node can access the account. This is suitable to check to see if the node's addon settings is still connected to an external account (i.e., the user hasn't revoked it in their user settings pane). If metadata is provided, this checks to see that all key/value pairs have been granted. This is suitable for checking access to a particular folder or other resource on an external provider. """ metadata = metadata or {} # ensure the grant exists try: grants = self.oauth_grants[node._id][external_account._id] except KeyError: return False # Verify every key/value pair is in the grants dict for key, value in metadata.items(): if key not in grants or grants[key] != value: return False return True def get_nodes_with_oauth_grants(self, external_account): # Generator of nodes which have grants for this external account for node_id, grants in self.oauth_grants.items(): node = AbstractNode.load(node_id) if external_account._id in grants.keys() and not node.is_deleted: yield node def get_attached_nodes(self, external_account): for node in self.get_nodes_with_oauth_grants(external_account): if node is None: continue node_settings = node.get_addon(self.oauth_provider.short_name) if node_settings is None: continue if node_settings.external_account == external_account: yield node def merge(self, user_settings): """Merge `user_settings` into this instance""" if user_settings.__class__ is not self.__class__: raise TypeError('Cannot merge different addons') for node_id, data in user_settings.oauth_grants.items(): if node_id not in self.oauth_grants: self.oauth_grants[node_id] = data else: node_grants = user_settings.oauth_grants[node_id].items() for ext_acct, meta in node_grants: if ext_acct not in self.oauth_grants[node_id]: self.oauth_grants[node_id][ext_acct] = meta else: for k, v in meta: if k not in self.oauth_grants[node_id][ext_acct]: self.oauth_grants[node_id][ext_acct][k] = v user_settings.oauth_grants = {} user_settings.save() try: config = settings.ADDONS_AVAILABLE_DICT[ self.oauth_provider.short_name] Model = config.models['nodesettings'] except KeyError: pass else: Model.objects.filter(user_settings=user_settings).update( user_settings=self) self.save() def to_json(self, user): ret = super(BaseOAuthUserSettings, self).to_json(user) ret['accounts'] = self.serializer( user_settings=self).serialized_accounts return ret ############# # Callbacks # ############# def on_delete(self): """When the user deactivates the addon, clear auth for connected nodes. """ super(BaseOAuthUserSettings, self).on_delete() nodes = [ AbstractNode.load(node_id) for node_id in self.oauth_grants.keys() ] for node in nodes: node_addon = node.get_addon(self.oauth_provider.short_name) if node_addon and node_addon.user_settings == self: node_addon.clear_auth()
class PreprintProvider(ObjectIDMixin, BaseModel): PUSH_SHARE_TYPE_CHOICES = (('Preprint', 'Preprint'), ('Thesis', 'Thesis'),) PUSH_SHARE_TYPE_HELP = 'This SHARE type will be used when pushing publications to SHARE' name = models.CharField(null=False, max_length=128) # max length on prod: 22 description = models.TextField(default='', blank=True) domain = models.URLField(blank=True, default='', max_length=200) domain_redirect_enabled = models.BooleanField(default=False) external_url = models.URLField(null=True, blank=True, max_length=200) # max length on prod: 25 email_contact = models.CharField(null=True, blank=True, max_length=200) # max length on prod: 23 email_support = models.CharField(null=True, blank=True, max_length=200) # max length on prod: 23 example = models.CharField(null=True, blank=True, max_length=20) # max length on prod: 5 access_token = EncryptedTextField(null=True, blank=True) advisory_board = models.TextField(default='', blank=True) social_twitter = models.CharField(null=True, blank=True, max_length=200) # max length on prod: 8 social_facebook = models.CharField(null=True, blank=True, max_length=200) # max length on prod: 8 social_instagram = models.CharField(null=True, blank=True, max_length=200) # max length on prod: 8 footer_links = models.TextField(default='', blank=True) share_publish_type = models.CharField(choices=PUSH_SHARE_TYPE_CHOICES, default='Preprint', help_text=PUSH_SHARE_TYPE_HELP, max_length=32) share_source = models.CharField(blank=True, max_length=200) share_title = models.TextField(default='', blank=True) allow_submissions = models.BooleanField(default=True) additional_providers = fields.ArrayField(models.CharField(max_length=200), default=list, blank=True) PREPRINT_WORD_CHOICES = ( ('preprint', 'Preprint'), ('paper', 'Paper'), ('thesis', 'Thesis'), ('none', 'None') ) preprint_word = models.CharField(max_length=10, choices=PREPRINT_WORD_CHOICES, default='preprint') subjects_acceptable = DateTimeAwareJSONField(blank=True, default=list) licenses_acceptable = models.ManyToManyField(NodeLicense, blank=True, related_name='licenses_acceptable') default_license = models.ForeignKey(NodeLicense, blank=True, related_name='default_license', null=True) class Meta: # custom permissions for use in the OSF Admin App permissions = ( ('view_preprintprovider', 'Can view preprint provider details'), ) def __unicode__(self): return '{} with id {}'.format(self.name, self.id) @property def highlighted_subjects(self): if self.subjects.filter(highlighted=True).exists(): return self.subjects.filter(highlighted=True).order_by('text')[:10] else: return sorted(self.top_level_subjects, key=lambda s: s.text)[:10] @property def top_level_subjects(self): if self.subjects.exists(): return self.subjects.filter(parent__isnull=True) else: # TODO: Delet this when all PreprintProviders have a mapping if len(self.subjects_acceptable) == 0: return Subject.objects.filter(parent__isnull=True, provider___id='osf') tops = set([sub[0][0] for sub in self.subjects_acceptable]) return [Subject.load(sub) for sub in tops] @property def all_subjects(self): if self.subjects.exists(): return self.subjects.all() else: # TODO: Delet this when all PreprintProviders have a mapping return rules_to_subjects(self.subjects_acceptable) def get_absolute_url(self): return '{}preprint_providers/{}'.format(self.absolute_api_v2_url, self._id) @property def absolute_api_v2_url(self): path = '/preprint_providers/{}/'.format(self._id) return api_v2_url(path)