diff --git a/data/database.py b/data/database.py index b49c8a594..51eeabf6f 100644 --- a/data/database.py +++ b/data/database.py @@ -4,7 +4,9 @@ import uuid from random import SystemRandom from datetime import datetime -from peewee import * +from peewee import (Proxy, MySQLDatabase, SqliteDatabase, PostgresqlDatabase, fn, CharField, + BooleanField, IntegerField, DateTimeField, ForeignKeyField, TextField, + BigIntegerField) from data.read_slave import ReadSlaveModel from sqlalchemy.engine.url import make_url from util.names import urn_generator @@ -265,6 +267,20 @@ class Repository(BaseModel): super(Repository, self).delete_instance(recursive=False, delete_nullable=False) +class Star(BaseModel): + user = ForeignKeyField(User, index=True, related_name="stars") + repository = ForeignKeyField(Repository, index=True, related_name="stargazers") + created = DateTimeField(default=datetime.now) + + class Meta: + database = db + read_slaves = (read_slave,) + indexes = ( + # create a unique index on user and repository + (('user', 'repository'), True), + ) + + class Role(BaseModel): name = CharField(index=True, unique=True) @@ -550,4 +566,4 @@ all_models = [User, Repository, Image, AccessToken, Role, RepositoryPermission, Notification, ImageStorageLocation, ImageStoragePlacement, ExternalNotificationEvent, ExternalNotificationMethod, RepositoryNotification, RepositoryAuthorizedEmail, ImageStorageTransformation, DerivedImageStorage, - TeamMemberInvite] + TeamMemberInvite, Star] diff --git a/data/migrations/versions/3b668be15dc0_add_stars.py b/data/migrations/versions/3b668be15dc0_add_stars.py new file mode 100644 index 000000000..b2f6545ef --- /dev/null +++ b/data/migrations/versions/3b668be15dc0_add_stars.py @@ -0,0 +1,42 @@ +"""add stars + +Revision ID: 3b668be15dc0 +Revises: 204abf14783d +Create Date: 2014-11-14 14:11:18.687340 + +""" + +# revision identifiers, used by Alembic. +revision = '3b668be15dc0' +down_revision = '204abf14783d' + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +def upgrade(tables): + ### commands auto generated by Alembic - please adjust! ### + op.create_table('star', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('repository_id', sa.Integer(), nullable=False), + sa.Column('created', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['repository_id'], ['repository.id'], name=op.f('fk_star_repository_id_repository')), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], name=op.f('fk_star_user_id_user')), + sa.PrimaryKeyConstraint('id', name=op.f('pk_star')) + ) + op.create_index('star_repository_id', 'star', ['repository_id'], unique=False) + op.create_index('star_user_id', 'star', ['user_id'], unique=False) + op.create_index('star_user_id_repository_id', 'star', ['user_id', 'repository_id'], unique=True) + ### end Alembic commands ### + + +def downgrade(tables): + ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint('fk_star_repository_id_repository', 'star', type_='foreignkey') + op.drop_constraint('fk_star_user_id_user', 'star', type_='foreignkey') + op.drop_index('star_user_id_repository_id', table_name='star') + op.drop_index('star_user_id', table_name='star') + op.drop_index('star_repository_id', table_name='star') + op.drop_table('star') + ### end Alembic commands ### diff --git a/data/model/legacy.py b/data/model/legacy.py index a5c779871..e7d05a9aa 100644 --- a/data/model/legacy.py +++ b/data/model/legacy.py @@ -2385,3 +2385,54 @@ def archivable_buildlogs_query(): .where((RepositoryBuild.phase == BUILD_PHASE.COMPLETE) | (RepositoryBuild.phase == BUILD_PHASE.ERROR) | (RepositoryBuild.started < presumed_dead_date), RepositoryBuild.logs_archived == False)) + + +def star_repository(user, repository): + """ Stars a repository. """ + star = Star.create(user=user.id, repository=repository.id) + star.save() + + +def unstar_repository(user, repository): + """ Unstars a repository. """ + try: + star = (Star + .select() + .join(Repository) + .switch(Star) + .join(User) + .where(Repository.id == repository.id, User.id == user.id) + .get()) + except Star.DoesNotExist: + raise DataModelException('Star not found.') + + star.delete_instance() + + +def get_user_starred_repositories(user, limit=None, page=None): + """ Retrieves all of the repositories a user has starred. """ + query = (Repository + .select() + .join(Star) + .join(User) + .where(User.id == user.id) + .order_by(Star.created)) + + if page and limit: + query = query.paginate(page, limit) + elif limit: + query = query.limit(limit) + + return query + + +def repository_is_starred(user, repository): + """ Determines whether a user has starred a repository or not. """ + try: + (Star + .select() + .where(Star.repository == repository.id, Star.user == user.id) + .get()) + return True + except Star.DoesNotExist: + return False diff --git a/endpoints/api/repository.py b/endpoints/api/repository.py index 0a3acdcd7..9302f26a0 100644 --- a/endpoints/api/repository.py +++ b/endpoints/api/repository.py @@ -102,21 +102,13 @@ class RepositoryList(ApiResource): @query_param('limit', 'Limit on the number of results (int)', type=int) @query_param('namespace', 'Namespace to use when querying for org repositories.', type=str) @query_param('public', 'Whether to include public repositories.', type=truthy_bool, default=True) - @query_param('private', 'Whether to inlcude private repositories.', type=truthy_bool, + @query_param('private', 'Whether to include private repositories.', type=truthy_bool, default=True) @query_param('sort', 'Whether to sort the results.', type=truthy_bool, default=False) @query_param('count', 'Whether to include a count of the total number of results available.', type=truthy_bool, default=False) def get(self, args): """Fetch the list of repositories under a variety of situations.""" - def repo_view(repo_obj): - return { - 'namespace': repo_obj.namespace_user.username, - 'name': repo_obj.name, - 'description': repo_obj.description, - 'is_public': repo_obj.visibility.name == 'public', - } - username = None if get_authenticated_user() and args['private']: username = get_authenticated_user().username @@ -141,6 +133,15 @@ class RepositoryList(ApiResource): return response +def repo_view(repo_obj): + return { + 'namespace': repo_obj.namespace_user.username, + 'name': repo_obj.name, + 'description': repo_obj.description, + 'is_public': repo_obj.visibility.name == 'public', + } + + @resource('/v1/repository/') @path_param('repository', 'The full path of the repository. e.g. namespace/name') class Repository(RepositoryParamResource): @@ -271,6 +272,4 @@ class RepositoryVisibility(RepositoryParamResource): log_action('change_repo_visibility', namespace, {'repo': repository, 'visibility': values['visibility']}, repo=repo) - return { - 'success': True - } + return {'success': True} diff --git a/endpoints/api/user.py b/endpoints/api/user.py index b713b3ff8..cacf111be 100644 --- a/endpoints/api/user.py +++ b/endpoints/api/user.py @@ -9,10 +9,12 @@ from app import app, billing as stripe, authentication, avatar from endpoints.api import (ApiResource, nickname, resource, validate_json_request, request_error, log_action, internal_only, NotFound, require_user_admin, parse_args, query_param, InvalidToken, require_scope, format_date, hide_if, show_if, - license_error, require_fresh_login, path_param, define_json_response) + license_error, require_fresh_login, path_param, define_json_response, + RepositoryParamResource) from endpoints.api.subscribe import subscribe from endpoints.common import common_login from endpoints.api.team import try_accept_invite +from endpoints.api.repository import repo_view from data import model from data.billing import get_plan @@ -247,7 +249,6 @@ class User(ApiResource): raise request_error(message='Username is already in use') model.change_username(user, new_username) - except model.InvalidPasswordException, ex: raise request_error(exception=ex) @@ -663,3 +664,75 @@ class UserAuthorization(ApiResource): access_token.delete_instance(recursive=True, delete_nullable=True) return 'Deleted', 204 + +@resource('/v1/user/starred') +class StarredRepositoryList(ApiResource): + """ Operations for creating and listing starred repositories. """ + schemas = { + 'NewStarredRepository': { + 'id': 'NewStarredRepository', + 'type': 'object', + 'required': [ + 'namespace', + 'repository', + ], + 'properties': { + 'namespace': { + 'type': 'string', + 'description': 'Namespace in which the repository belongs', + }, + 'repository': { + 'type': 'string', + 'description': 'Repository name' + } + } + } + } + + @require_scope(scopes.READ_REPO) + @nickname('listStarredRepos') + @parse_args + @query_param('page', 'Offset page number. (int)', type=int) + @query_param('limit', 'Limit on the number of results (int)', type=int) + def get(self): + """ List all starred repositories. """ + page = args['page'] + limit = args['limit'] + starred_repos = list(get_user_starred_repositories(get_authenticated_user(), page=page, limit=limit)) + return {'repositories': [repo_view(repo) for repo in starred_repos]} + + @require_scope(scopes.READ_REPO) + @nickname('createStar') + @validate_json_request('NewStarredRepository') + def post(self): + """ Star a repository. """ + user = get_authenticated_user() + req = request.get_json() + namespace = req['namespace'] + repository = req['repository'] + repo = model.get_repository(namespace, repository) + if repo: + model.star_repository(user, repo) + log_action('star_repository', user.username, namespace, + {'repo': repository, 'namespace': namespace}) + return { + 'namespace': namespace, + 'repository': repository, + }, 201 + + raise NotFound() + +@resource('/v1/user/starred/') +class StarredRepository(RepositoryParamResource): + """ Operations for managing a specific starred repository. """ + @nickname('deleteStar') + def delete(self, namespace, repository): + user = get_authenticated_user() + repo = model.get_repository(namespace, repository) + if repo: + model.unstar_repository(user, repo) + log_action('unstar_repository', user.username, namespace, + {'repo': repository, 'namespace': namespace}) + return 'Deleted', 204 + + raise NotFound()