Exemple #1
0
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)
Exemple #4
0
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.'), )
Exemple #5
0
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')
Exemple #6
0
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
Exemple #7
0
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.'), )
Exemple #9
0
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'
Exemple #10
0
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
Exemple #11
0
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,
        )
Exemple #12
0
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
Exemple #13
0
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.')
Exemple #15
0
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)
Exemple #16
0
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.'), )
Exemple #17
0
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)
Exemple #18
0
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')
Exemple #19
0
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)
Exemple #20
0
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'), )
Exemple #21
0
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
Exemple #22
0
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
Exemple #23
0
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)
Exemple #24
0
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)
Exemple #25
0
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', )
Exemple #26
0
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
Exemple #27
0
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
Exemple #28
0
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
Exemple #29
0
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()
Exemple #30
0
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)