예제 #1
0
class PostFile(FileLinkMixin, db.Model):
    __tablename__ = "post_files"

    id = db.Column(db.Integer, primary_key=True)
    post_id = db.Column(db.Integer, db.ForeignKey('post.id', ondelete='CASCADE'), index=True, nullable=False)
    post = db.relationship('Post')
    file_id = db.Column(db.Integer, db.ForeignKey('file.id', ondelete="CASCADE"), index=True, nullable=False)
    file = db.relationship('File', lazy='joined')
예제 #2
0
class Redirect(db.Model):
    __tablename__ = 'redirect'

    id = db.Column(db.Integer, primary_key=True)
    nid = db.Column(db.Integer)
    old_url = db.Column(db.String(250),
                        nullable=False,
                        unique=True,
                        index=True)
    new_url = db.Column(db.String(250))

    def __str__(self):
        if self.nid:
            target = "nid %s" % self.nid
        else:
            target = self.new_url

        return u'<Redirect from %s to %s>' % (self.old_url, target)

    @classmethod
    def object_for_nid(cls, nid):
        for cls in resource_slugs.itervalues():
            if hasattr(cls, 'nid'):
                obj = cls.query.filter(cls.nid == nid).first()
                if obj:
                    return obj

    @classmethod
    def for_url(cls, old_url):
        dest = None
        nid = None

        if old_url.endswith("/"):
            old_url = old_url[:-1]

        # check for /node/1234
        match = re.match('^/node/(\d+)$', old_url)
        if match:
            nid = match.group(1)

        else:
            redirect = cls.query.filter(cls.old_url == old_url).first()
            if redirect:
                if redirect.new_url:
                    dest = redirect.new_url
                elif redirect.nid:
                    nid = redirect.nid

        if nid:
            # lookup based on nid
            obj = cls.object_for_nid(nid)
            if obj:
                dest = obj.url

        if dest and not dest.startswith('http') and not dest.startswith('/'):
            dest = '/' + dest

        return dest
예제 #3
0
class Role(db.Model, RoleMixin):

    __tablename__ = "role"

    id = db.Column(db.Integer(), primary_key=True)
    name = db.Column(db.String(80), unique=True)
    description = db.Column(db.String(255))

    def __unicode__(self):
        return unicode(self.name)
예제 #4
0
class Page(db.Model):
    """ A basic CMS page. """
    __tablename__ = 'page'

    id = db.Column(db.Integer, primary_key=True)
    title = db.Column(db.String, nullable=False)
    slug = db.Column(db.String, nullable=False, unique=True, index=True)
    body = db.Column(db.Text)
    created_at = db.Column(db.DateTime(timezone=True),
                           index=True,
                           unique=False,
                           nullable=False,
                           server_default=func.now())
    updated_at = db.Column(db.DateTime(timezone=True),
                           server_default=func.now(),
                           onupdate=func.current_timestamp())

    files = db.relationship("PageFile", lazy='joined')
    show_files = db.Column(db.Boolean,
                           nullable=False,
                           default=True,
                           server_default=sql.expression.true())
    featured = db.Column(db.Boolean(),
                         default=False,
                         server_default=sql.expression.false(),
                         nullable=False,
                         index=True)

    @validates('slug')
    def validate_slug(self, key, value):
        return value.strip('/')

    def to_dict(self, include_related=False):
        tmp = serializers.model_to_dict(self, include_related=include_related)
        return tmp
예제 #5
0
class SoundcloudTrackRetry(db.Model):
    __tablename__ = "soundcloud_track_retry"

    id = db.Column(db.Integer, primary_key=True)
    created_at = db.Column(
        db.DateTime(timezone=True),
        nullable=False,
        default=datetime.utcnow
    )
    updated_at = db.Column(
        db.DateTime(timezone=True),
        nullable=False,
        default=datetime.utcnow,
        onupdate=func.current_timestamp()
    )
    soundcloud_track_id = db.Column(
        db.Integer,
        db.ForeignKey('soundcloud_track.id'),
        nullable=False,
        unique=False
    )
    soundcloud_track = db.relationship('SoundcloudTrack', backref=backref('retries', lazy='joined'), lazy=True)
예제 #6
0
class Post(db.Model):
    __tablename__ = 'post'

    id = db.Column(db.Integer, primary_key=True)
    title = db.Column(db.String, nullable=False)
    slug = db.Column(db.String, nullable=False, unique=True, index=True)
    featured = db.Column(db.Boolean(), default=False, server_default=sql.expression.false(), nullable=False, index=True)
    body = db.Column(db.Text)
    date = db.Column(db.DateTime(timezone=True), index=True, unique=False, nullable=False, server_default=func.now())
    files = db.relationship("PostFile", lazy='joined')
    created_at = db.Column(db.DateTime(timezone=True), index=True, unique=False, nullable=False, server_default=func.now())
    updated_at = db.Column(db.DateTime(timezone=True), server_default=func.now(), onupdate=func.current_timestamp())

    @validates('slug')
    def validate_slug(self, key, value):
        return value.strip('/')

    def __unicode__(self):
        return unicode(self.title)
예제 #7
0
class Organisation(db.Model):

    __tablename__ = "organisation"

    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(255), nullable=False)
    domain = db.Column(db.String(100), nullable=False)
    paid_subscriber = db.Column(db.Boolean)
    created_at = db.Column(db.DateTime(timezone=True),
                           default=datetime.datetime.now)
    # when does this subscription expire?
    expiry = db.Column(db.Date(), default=one_year_later)
    contact = db.Column(db.String(255))

    # premium committee subscriptions
    subscriptions = db.relationship('Committee',
                                    secondary='organisation_committee',
                                    passive_deletes=True)

    def subscribed_to_committee(self, committee):
        """ Does this organisation have an active subscription to `committee`? """
        return not self.has_expired() and (committee in self.subscriptions)

    def has_expired(self):
        return (self.expiry
                is not None) and (datetime.date.today() > self.expiry)

    def __unicode__(self):
        return unicode(self.name)

    def to_dict(self, include_related=False):
        tmp = serializers.model_to_dict(self, include_related=include_related)
        # send subscriptions back as a dict
        subscription_dict = {}
        if tmp.get('subscriptions'):
            for committee in tmp['subscriptions']:
                subscription_dict[committee['id']] = committee.get('name')
        tmp['subscriptions'] = subscription_dict
        # set 'has_expired' flag as appropriate
        tmp['has_expired'] = self.has_expired()
        return tmp
예제 #8
0
class EmailTemplate(db.Model):
    __tablename__ = 'email_template'

    id = db.Column(db.Integer, primary_key=True)

    name = db.Column(db.String(100), nullable=False)
    description = db.Column(db.String(1024))
    subject = db.Column(db.String(100))
    body = db.Column(db.Text)

    created_at = db.Column(db.DateTime(timezone=True),
                           index=True,
                           unique=False,
                           nullable=False,
                           server_default=func.now())
    updated_at = db.Column(db.DateTime(timezone=True),
                           server_default=func.now(),
                           onupdate=func.current_timestamp())

    @property
    def utm_campaign(self):
        return re.sub(r'[^a-z0-9 -]+', '', self.name.lower()).replace(' ', '-')
예제 #9
0
class SoundcloudTrack(db.Model):
    """
    - Tracks where uri and state is null are either busy being uploaded,
      or failed to upload to SoundCloud.
    - Tracks where state is 'failed' reflect that soundcloud
      failed to process the track.
    """
    __tablename__ = "soundcloud_track"

    id = db.Column(db.Integer, primary_key=True)
    created_at = db.Column(db.DateTime(timezone=True),
                           nullable=False,
                           default=datetime.utcnow)
    updated_at = db.Column(db.DateTime(timezone=True),
                           nullable=False,
                           default=datetime.utcnow,
                           onupdate=func.current_timestamp())
    file_id = db.Column(db.Integer,
                        db.ForeignKey('file.id'),
                        nullable=False,
                        unique=True)
    file = db.relationship('File',
                           backref=backref('soundcloud_track',
                                           uselist=False,
                                           lazy='joined'),
                           lazy=True)
    # Soundcloud resource URI for the track (i.e. https://api.soundcloud...id)
    uri = db.Column(db.String())
    # Last known value of SoundCloud's opinion of the track state
    state = db.Column(db.String())

    def __str__(self):
        return unicode(self).encode('utf-8')

    def __unicode__(self):
        return u'<SoundcloudTrack id=%d>' % self.id

    @classmethod
    def new_from_file(cls, client, file):
        if db.session.query(
                cls.id).filter(cls.file_id == file.id).scalar() is not None:
            logging.info(
                "File already started being uploaded to Soundcloud: %s" % file)
            db.session.rollback()
            return
        # Immediately create the SoundcloudTrack to indicate that work
        # has started for this track and may be in progress.
        # Potential concurrent runs can rely on an exception here to avoid
        # uploading the same file twice.
        soundcloud_track = cls(file=file)
        db.session.add(soundcloud_track)
        db.session.commit()

        with file.open() as file_handle:
            logging.info("Uploading to SoundCloud: %s" % file)
            track = client.post('/tracks',
                                track={
                                    'title':
                                    file.title,
                                    'description':
                                    cls._html_description(file),
                                    'sharing':
                                    'public',
                                    'asset_data':
                                    file_handle,
                                    'license':
                                    'cc-by',
                                    'artwork_data':
                                    open(SOUNDCLOUD_ARTWORK_PATH, 'rb'),
                                    'genre':
                                    file.event_files[0].event.type,
                                    'tag_list':
                                    file.event_files[0].event.type,
                                    'downloadable':
                                    'true',
                                    'streamable':
                                    'true',
                                    'feedable':
                                    'true',
                                })
            logging.info("Done uploading to SoundCloud: %s" % file)
            file_handle.close()

            soundcloud_track.uri = track.uri
            soundcloud_track.state = track.state
            db.session.commit()

    @staticmethod
    def _html_description(file):
        """
        HTML description for presentation on Soundcloud.
        staticmethod because it's needed before the instance exists.
        """
        return 'Sound recording from:<br>' + \
            '<br>'.join(
                "<a href='%s'>%s</a>" % (ef.event.url, ef.event.title)
                for ef in file.event_files
            )

    def sync_state(self, client):
        track = client.get(self.uri)
        if track.state != self.state:
            self.state = track.state
            db.session.commit()
            logger.info("SoundCloud track %s state is now [%s]" %
                        (self, self.state))

    @classmethod
    def upload_files(cls, client):
        q = cls.get_unstarted_query()
        logging.info("Audio files yet to be uploaded to SoundCloud: %d" %
                     cls.get_unstarted_count(q))
        batch = cls.get_unstarted_batch(q)
        # Rollback this transaction - it was just to gather candidates for upload
        db.session.rollback()
        logging.info("Uploading %d files to SoundCloud" % len(batch))
        for file in batch:
            cls.new_from_file(client, file)

    @classmethod
    def sync(cls):
        client = Client(client_id=app.config['SOUNDCLOUD_APP_KEY_ID'],
                        client_secret=app.config['SOUNDCLOUD_APP_KEY_SECRET'],
                        username=app.config['SOUNDCLOUD_USERNAME'],
                        password=app.config['SOUNDCLOUD_PASSWORD'])
        cls.upload_files(client)
        cls.sync_upload_state(client)

    @classmethod
    def get_unstarted_query(cls):
        """
        Get audio files for which there's no SoundcloudTrack.
        Order by id as a hacky way to roughly get the latest files first
        """
        # Query files that aren't connected to events so we can ignore them for
        # now - it's not clear that they're actually visible - they might well
        # have been mistaken uploads that shouldn't suddenly appear on PMG's
        # public soundcloud profile.
        q_files_with_meetings = db.session.query(File.id) \
                                          .outerjoin(EventFile) \
                                          .filter(EventFile.file_id == None) \
                                          .filter(File.file_mime.like('audio/%'))
        return db.session.query(File) \
                         .outerjoin(cls) \
                         .filter(cls.file_id == None) \
                         .filter(File.file_mime.like('audio/%')) \
                         .filter(~File.id.in_(q_files_with_meetings)) \
                         .order_by(desc(File.id))

    @staticmethod
    def get_unstarted_count(q):
        return q.count()

    @staticmethod
    def get_unstarted_batch(q):
        return q.limit(app.config['MAX_SOUNDCLOUD_BATCH']).all()

    @classmethod
    def sync_upload_state(cls, client):
        tracks = db.session.query(cls) \
                           .filter(cls.state.in_(UNFINISHED_STATES)) \
                           .order_by(cls.created_at).all()
        for track in tracks:
            track.sync_state(client)
예제 #10
0
class User(db.Model, UserMixin):

    __tablename__ = "user"

    id = db.Column(db.Integer, primary_key=True)
    email = db.Column(db.String(255), unique=True, nullable=False, index=True)
    name = db.Column(db.String(255), nullable=True)
    password = db.Column(db.String(255), default='', server_default='', nullable=False)
    active = db.Column(db.Boolean(), default=True, server_default=sql.expression.true())
    confirmed_at = db.Column(db.DateTime(timezone=True))
    last_login_at = db.Column(db.DateTime(timezone=True))
    current_login_at = db.Column(db.DateTime(timezone=True))
    last_login_ip = db.Column(db.String(100))
    current_login_ip = db.Column(db.String(100))
    login_count = db.Column(db.Integer)
    subscribe_daily_schedule = db.Column(db.Boolean(), default=False)
    created_at = db.Column(db.DateTime(timezone=True), index=True, unique=False, nullable=False, server_default=func.now())
    updated_at = db.Column(db.DateTime(timezone=True), index=True, unique=False, nullable=False, server_default=func.now(), onupdate=func.current_timestamp())
    # when does this subscription expire?
    expiry = db.Column(db.Date(), default=one_year_later)

    organisation_id = db.Column(db.Integer, db.ForeignKey('organisation.id'))
    organisation = db.relationship('Organisation', backref='users', lazy=False, foreign_keys=[organisation_id])

    # premium committee subscriptions, in addition to any that the user's organisation might have
    subscriptions = db.relationship('Committee', secondary='user_committee', passive_deletes=True)

    # committees that the user chooses to follow
    following = db.relationship('Committee', secondary='user_following', passive_deletes=True)

    # alerts for changes to committees
    committee_alerts = db.relationship('Committee', secondary='user_committee_alerts', passive_deletes=True, lazy='joined')
    roles = db.relationship('Role', secondary='roles_users', backref=db.backref('users', lazy='dynamic'))

    def __unicode__(self):
        return unicode(self.email)

    def is_confirmed(self):
        return self.confirmed_at is not None

    def has_expired(self):
        return (self.expiry is not None) and (datetime.date.today() > self.expiry)

    def update_current_login(self):
        now = datetime.datetime.utcnow()
        if self.current_login_at.replace(tzinfo=None) + datetime.timedelta(hours=1) < now:
            self.current_login_at = now
            db.session.commit()

    def subscribed_to_committee(self, committee):
        """ Does this user have an active subscription to `committee`? """
        # admin users have access to everything
        if self.has_role('editor'):
            return True

        # inactive users should go away
        if not self.active:
            return False

        # expired users should go away
        if self.has_expired():
            return False

        # first see if this user has a subscription
        if committee in self.subscriptions:
            return True

        # now check if our organisation has access
        return self.organisation and self.organisation.subscribed_to_committee(committee)

    def gets_alerts_for(self, committee):
        from ..models.resources import Committee
        if not isinstance(committee, Committee):
            committee = Committee.query.get(committee)
        return committee in self.committee_alerts

    def follows(self, committee):
        from ..models.resources import Committee
        if not isinstance(committee, Committee):
            committee = Committee.query.get(committee)
        return committee in self.following

    def get_followed_committee_meetings(self):
        from ..models.resources import CommitteeMeeting
        following = CommitteeMeeting.committee_id.in_([f.id for f in self.following])
        return CommitteeMeeting.query.filter(following).order_by(desc(CommitteeMeeting.date))

    def follow_committee(self, committee):
        from ..models.resources import Committee
        if not isinstance(committee, Committee):
            committee = Committee.query.get(committee)
        self.following.append(committee)

    def unfollow_committee(self, committee):
        from ..models.resources import Committee
        if not isinstance(committee, Committee):
            committee = Committee.query.get(committee)
        self.following.remove(committee)

    @validates('organisation')
    def validate_organisation(self, key, org):
        if org:
            self.expiry = org.expiry
        return org

    @validates('email')
    def validate_email(self, key, email):
        if email:
            email = email.lower()
        if not self.organisation and email:
            user_domain = email.split("@")[-1]
            self.organisation = Organisation.query.filter_by(domain=user_domain).first()
        return email

    def to_dict(self, include_related=False):
        tmp = serializers.model_to_dict(self, include_related=include_related)
        tmp.pop('password')
        tmp.pop('last_login_ip')
        tmp.pop('current_login_ip')
        tmp.pop('last_login_at')
        tmp.pop('current_login_at')
        tmp.pop('confirmed_at')
        tmp.pop('login_count')
        tmp['has_expired'] = self.has_expired()

        # send committee alerts back as a dict
        alerts_dict = {}
        if tmp.get('committee_alerts'):
            for committee in tmp['committee_alerts']:
                alerts_dict[committee['id']] = committee.get('name')
        tmp['committee_alerts'] = alerts_dict
        return tmp
예제 #11
0
    )
    app.extensions.get('mail').send(msg)


def subscribe_to_newsletter(user):
    """ Add this user to the sharpspring PMG Monitor newsletter mailing list
    """
    if app.config.get('SHARPSPRING_API_SECRET'):
        from pmg.sharpspring import Sharpspring
        Sharpspring().subscribeToList(user, '310799364')


roles_users = db.Table(
    'roles_users',
    db.Column(
        'user_id',
        db.Integer(),
        db.ForeignKey('user.id')),
    db.Column(
        'role_id',
        db.Integer(),
        db.ForeignKey('role.id')))

organisation_committee = db.Table(
    'organisation_committee',
    db.Column(
        'organisation_id',
        db.Integer(),
        db.ForeignKey('organisation.id', ondelete='CASCADE')),
    db.Column(
        'committee_id',
        db.Integer(),
예제 #12
0
class SavedSearch(db.Model):
    """ A search saved by a user that they get email
    alerts about.
    """
    __tablename__ = 'saved_search'

    id = db.Column(db.Integer, primary_key=True)

    user_id = db.Column(db.Integer,
                        db.ForeignKey('user.id', ondelete='CASCADE'),
                        nullable=False)
    user = db.relationship('User', backref='saved_searches', lazy=True)
    # search terms
    search = db.Column(db.String(255), nullable=False)
    # only search for some items?
    content_type = db.Column(db.String(255))
    # only search linked to a committee?
    committee_id = db.Column(db.Integer,
                             db.ForeignKey('committee.id', ondelete='CASCADE'))
    committee = db.relationship('Committee', lazy=True)

    # The last time an alert was sent. We compare new search results to this to determine
    # if they're fresh
    last_alerted_at = db.Column(db.DateTime(timezone=True),
                                nullable=False,
                                server_default=func.now())

    created_at = db.Column(db.DateTime(timezone=True),
                           index=True,
                           unique=False,
                           nullable=False,
                           server_default=func.now())
    updated_at = db.Column(db.DateTime(timezone=True),
                           server_default=func.now(),
                           onupdate=func.current_timestamp())

    def check_and_send_alert(self):
        """ Check if there are new items for this search and send an
        alert if there are.
        """
        hits = self.find_new_hits()
        if hits:
            log.info("Found %d new results for saved search %s" %
                     (len(hits), self))
            self.send_alert(hits)

    def send_alert(self, hits):
        """ Send an email alert for the search results in +hits+.

        NOTE: this commits the database session, to prevent later errors from causing
        us to send duplicate emails.
        """
        # we embed this into the actual email template
        html = render_template('saved_search_alert.html',
                               search=self,
                               results=hits)

        send_sendgrid_email(
            subject="New matches for your search '%s'" % self.search,
            from_name="PMG Notifications",
            from_email="*****@*****.**",
            recipient_users=[self.user],
            html=html,
            utm_campaign='searchalert',
        )

        # save that we sent this alert
        self.last_alerted_at = arrow.utcnow().datetime
        db.session.commit()

    def find_new_hits(self):
        from pmg.search import Search

        # find hits updated since the last time we did this search
        search = Search().search(self.search,
                                 document_type=self.content_type,
                                 committee=self.committee_id)

        if 'hits' not in search:
            log.warn("Error doing search for %s: %s" % (self, search))
            return

        timestamp = self.last_alerted_at.astimezone(pytz.utc)
        hits = search['hits']['hits']
        hits = [
            h for h in hits
            if arrow.get(h['_source']['updated_at']).datetime >= timestamp
        ]

        return hits

    def url(self, **kwargs):
        params = {'q': self.search}

        if self.content_type:
            params['filter[type]'] = self.content_type

        if self.committee_id:
            params['filter[committee]'] = self.committee_id

        params.update(kwargs)
        return url_for('search', **params)

    @property
    def friendly_content_type(self):
        from pmg.search import Search
        if self.content_type:
            return Search.friendly_data_types[self.content_type]

    def __repr__(self):
        return u'<SavedSearch id=%s user=%s>' % (self.id, self.user)

    @classmethod
    def send_all_alerts(cls):
        """ Find saved searches with new content and send the email alerts.
        """
        log.info("Sending all alerts")
        for alert in SavedSearch.query.all():
            alert.check_and_send_alert()
        log.info("Sending alerts finished")

    @classmethod
    def find(cls, user, q, content_type=None, committee_id=None):
        return cls.query.filter(cls.user == user, cls.search == q.lower(),
                                cls.content_type == content_type,
                                cls.committee_id == committee_id).first()

    @classmethod
    def find_or_create(cls, user, q, content_type=None, committee_id=None):
        search = cls.find(user, q, content_type, committee_id)
        if not search:
            search = cls(user=user,
                         search=q.lower(),
                         content_type=content_type,
                         committee_id=committee_id)
            search.last_alerted_at = arrow.utcnow().datetime
            db.session.add(search)
        return search
예제 #13
0
class SoundcloudTrack(db.Model):
    """
    - Tracks where uri and state is null are either busy being uploaded,
      or failed to upload to SoundCloud.
    - Tracks where state is 'failed' reflect that soundcloud
      failed to process the track.
    """
    __tablename__ = "soundcloud_track"

    id = db.Column(db.Integer, primary_key=True)
    created_at = db.Column(
        db.DateTime(timezone=True),
        nullable=False,
        default=datetime.utcnow
    )
    updated_at = db.Column(
        db.DateTime(timezone=True),
        nullable=False,
        default=datetime.utcnow,
        onupdate=func.current_timestamp()
    )
    file_id = db.Column(
        db.Integer,
        db.ForeignKey('file.id'),
        nullable=False,
        unique=True
    )
    file = db.relationship('File', backref=backref('soundcloud_track', uselist=False, lazy='joined'), lazy=True)
    # Soundcloud resource URI for the track (i.e. https://api.soundcloud...id)
    uri = db.Column(db.String())
    # Last known value of SoundCloud's opinion of the track state
    state = db.Column(db.String())

    def __str__(self):
        return unicode(self).encode('utf-8')

    def __unicode__(self):
        return u'<SoundcloudTrack id=%d>' % self.id

    @classmethod
    def new_from_file(cls, client, file):
        if db.session.query(cls.id).filter(cls.file_id == file.id).scalar() is not None:
            logging.info("File already started being uploaded to Soundcloud: %s" % file)
            db.session.rollback()
            return
        # Immediately create the SoundcloudTrack to indicate that work
        # has started for this track and may be in progress.
        # Potential concurrent runs can rely on an exception here to avoid
        # uploading the same file twice.
        soundcloud_track = cls(file=file)
        db.session.add(soundcloud_track)
        db.session.commit()
        soundcloud_track._upload(client)

    def _upload(self, client):
        with self.file.open() as file_handle:
            logging.info("Uploading to SoundCloud: %s" % self.file)
            track = client.post('/tracks', track={
                'title': self.file.title,
                'description': SoundcloudTrack._html_description(self.file),
                'sharing': 'public',
                'asset_data': file_handle,
                'license': 'cc-by',
                'artwork_data': open(SOUNDCLOUD_ARTWORK_PATH, 'rb'),
                'genre': self.file.event_files[0].event.type,
                'tag_list': self.file.event_files[0].event.type,
                'downloadable': 'true',
                'streamable': 'true',
                'feedable': 'true',
            })
            logging.info("Done uploading to SoundCloud: %s" % self.file)
            file_handle.close()

            self.uri = track.uri
            self.state = track.state
            db.session.commit()


    @staticmethod
    def _html_description(file):
        """
        HTML description for presentation on Soundcloud.
        staticmethod because it's needed before the instance exists.
        """
        return 'Sound recording from:<br>' + \
            '<br>'.join(
                "<a href='%s'>%s</a>" % (ef.event.url, ef.event.title)
                for ef in file.event_files
            )

    def sync_state(self, client):
        logging.info("Checking state of %s", self.uri)
        track = client.get(self.uri)
        if track.state != self.state:
            self.state = track.state
            db.session.commit()
            logger.info("SoundCloud track %s state is now [%s]" % (self, self.state))

    @classmethod
    def upload_files(cls, client):
        q = cls.get_unstarted_query()
        logging.info("Audio files yet to be uploaded to SoundCloud: %d"
                     % cls.get_unstarted_count(q))
        batch = cls.get_unstarted_batch(q)
        # Rollback this transaction - it was just to gather candidates for upload
        db.session.rollback()
        logging.info("Uploading %d files to SoundCloud" % len(batch))
        for file in batch:
            cls.new_from_file(client, file)

    @classmethod
    def sync(cls):
        client = Client(client_id=app.config['SOUNDCLOUD_APP_KEY_ID'],
                        client_secret=app.config['SOUNDCLOUD_APP_KEY_SECRET'],
                        username=app.config['SOUNDCLOUD_USERNAME'],
                        password=app.config['SOUNDCLOUD_PASSWORD'])
        cls.upload_files(client)
        cls.sync_upload_state(client)
        cls.handle_failed(client)

    @classmethod
    def get_unstarted_query(cls):
        """
        Get audio files for which there's no SoundcloudTrack.
        Order by id as a hacky way to roughly get the latest files first
        """
        # Query files that aren't connected to events so we can ignore them for
        # now - it's not clear that they're actually visible - they might well
        # have been mistaken uploads that shouldn't suddenly appear on PMG's
        # public soundcloud profile.
        q_files_with_meetings = db.session.query(File.id) \
                                          .outerjoin(EventFile) \
                                          .filter(EventFile.file_id == None) \
                                          .filter(File.file_mime.like('audio/%'))
        return db.session.query(File) \
                         .outerjoin(cls) \
                         .filter(cls.file_id == None) \
                         .filter(File.file_mime.like('audio/%')) \
                         .filter(~File.id.in_(q_files_with_meetings)) \
                         .order_by(desc(File.id))

    @staticmethod
    def get_unstarted_count(q):
        return q.count()

    @staticmethod
    def get_unstarted_batch(q):
        return q.limit(app.config['MAX_SOUNDCLOUD_BATCH']).all()

    @classmethod
    def sync_upload_state(cls, client):
        tracks = db.session.query(cls) \
                           .filter(cls.state.in_(UNFINISHED_STATES)) \
                           .order_by(cls.created_at).all()
        for track in tracks:
            track.sync_state(client)

    @classmethod
    def handle_failed(cls, client):
        for track_id, retries in db.session.query(cls.id,
                                                  func.count(SoundcloudTrackRetry.id).label('retries'))\
                                           .filter(cls.state == 'failed')\
                                           .outerjoin(SoundcloudTrackRetry)\
                                           .group_by(cls.id)\
                                           .order_by('retries')\
                                           .limit(app.config['MAX_SOUNDCLOUD_BATCH']):
            if retries <= app.config['MAX_SOUNDCLOUD_RETRIES']:
                soundcloud_track = db.session.query(cls).get(track_id)
                # Sometimes tracks apparently go from failed to finished. Yeah.
                try:
                    soundcloud_track.sync_state(client)
                except HTTPError:
                    logging.info("HTTP Error checking state of failed SoundCloud upload")
                if soundcloud_track.state == 'failed':
                    soundcloud_track.retry_upload(client)

    def retry_upload(self, client):
        logging.info("Retrying failed soundcloud track %r" % self)
        retry = SoundcloudTrackRetry(soundcloud_track=self)
        db.session.add(retry)
        db.session.commit()
        try:
            client.delete(self.uri)
        except HTTPError as delete_result:
            # Handle brokenness at SoundCloud where deleting a track
            # results with an HTTP 500 response yet the track
            # is gone (HTTP 404) when you try to GET it.
            if delete_result.response.status_code == 500:
                try:
                    client.get(self.uri)
                    # If the delete gves a 500 and the GET is successful, we're
                    # not sure it was deleted so don't continue the retry, just
                    # let the next retry try to delete again and continue if
                    # it's finished deleting by then.
                    logging.info(("Tried to delete but track %s but apparently " +\
                                  "it's still there") % self.uri)
                    return
                except HTTPError as get_result:
                    if get_result.response.status_code != 404:
                        raise Exception(("Can't tell if track %s that we attempted " +\
                                         "to delete is still there.") % self.uri)
            elif delete_result.response.status_code == 404:
                logging.info(("Track %s was already missing from SoundCloud when " +\
                              "to retry %r") % (self.uri, self))
            elif delete_result.response.status_code != 200:
                raise Exception(("Unexpected result when deleting %s from " +\
                                 "SoundCloud") % self)
        # If we get here we expect that we've successfully deleted
        # the failed track from SoundCloud.
        # Indicate that we've started uploading
        self.state = None
        db.session.commit()
        self._upload(client)