From 8366b2e8bd887b9abdb8eb7730de205e47aad83f Mon Sep 17 00:00:00 2001 From: Andrew Brain Date: Tue, 1 Nov 2022 18:27:09 -0500 Subject: [PATCH 001/150] Add user group functionality to repo load controller Signed-off-by: Andrew Brain --- augur/util/repo_load_controller.py | 62 ++++++++++++++++++++++++------ 1 file changed, 51 insertions(+), 11 deletions(-) diff --git a/augur/util/repo_load_controller.py b/augur/util/repo_load_controller.py index eac07235ae..422ceca1a2 100644 --- a/augur/util/repo_load_controller.py +++ b/augur/util/repo_load_controller.py @@ -19,6 +19,7 @@ ORG_REPOS_ENDPOINT = "https://api.github.com/orgs/{}/repos?per_page=100" DEFAULT_REPO_GROUP_ID = 1 CLI_USER_ID = 1 +CLI_GROUP_ID = 1 class RepoLoadController: @@ -82,6 +83,14 @@ def is_valid_repo(self, url: str) -> bool: return True + def is_valid_user_group(self, user_id, group_id) -> bool: + + try: + self.session.query(UserRepo).filter(UserRepo.user_id == user_id, UserRepo.group_id == group_id).one() + return True + except s.orm.exc.NoResultFound: + return False + def retrieve_org_repos(self, url: str) -> List[str]: """Get the repos for an org. @@ -182,7 +191,7 @@ def add_repo_row(self, url: str, repo_group_id: int, tool_source): return result[0]["repo_id"] - def add_repo_to_user(self, repo_id, user_id=1): + def add_repo_to_user_group(self, repo_id, group_id=CLI_GROUP_ID): """Add a repo to a user in the user_repos table. Args: @@ -190,22 +199,47 @@ def add_repo_to_user(self, repo_id, user_id=1): user_id: id of user_id from users table """ - repo_user_data = { - "user_id": user_id, + repo_user_group_data = { + "group_id": group_id, "repo_id": repo_id } - repo_user_unique = ["user_id", "repo_id"] - return_columns = ["user_id", "repo_id"] + repo_user_group_unique = ["group_id", "repo_id"] + return_columns = ["group_id", "repo_id"] data = self.session.insert_data(repo_user_data, UserRepo, repo_user_unique, return_columns) - if data[0]["user_id"] == user_id and data[0]["repo_id"] == repo_id: + if data[0]["group_id"] == group_id and data[0]["repo_id"] == repo_id: return True return False - def add_frontend_repo(self, url: List[str], user_id: int): + def add_user_group(self, user_id, group_name): + + user_group_data = { + "group_name": group_id, + "user_id": repo_id + } + + # TODO Add exception for duplicate groups + group_obj = UserGroup(**user_group_data) + self.session.add(group_obj) + self.session.commit() + + return True + + def get_user_groups(self, user_id): + + return self.session.query(UserGroup).filter(UserGroup.user_id == user_id).all() + + def get_user_group_repos(self, user_id, group_id): + + repos = self.session.query(UserRepo).filter(UserGroup.user_id == user_id, UserGroup.group_id = group_id).all() + + return [repo["repo_id"] for repo in repos] + + + def add_frontend_repo(self, url: List[str], user_id: int, group_id: int, valid_group=False): """Add list of repos to a users repos. Args: @@ -216,12 +250,15 @@ def add_frontend_repo(self, url: List[str], user_id: int): if not self.is_valid_repo(url): return {"status": "Invalid repo", "repo_url": url} + if not valid_group and not self.is_valid_user_group(user_id, group_id): + return {"status": "Invalid user group", "group_id": group_id} + repo_id = self.add_repo_row(url, DEFAULT_REPO_GROUP_ID, "Frontend") if not repo_id: return {"status": "Repo insertion failed", "repo_url": url} - result = self.add_repo_to_user(repo_id, user_id) + result = self.add_repo_to_user_group(repo_id, group_id) if not result: return {"status": "repo_user insertion failed", "repo_url": url} @@ -230,7 +267,7 @@ def add_frontend_repo(self, url: List[str], user_id: int): - def add_frontend_org(self, url: List[str], user_id: int): + def add_frontend_org(self, url: List[str], user_id: int, group_id: int): """Add list of orgs and their repos to a users repos. Args: @@ -242,11 +279,14 @@ def add_frontend_org(self, url: List[str], user_id: int): if not repos: return {"status": "Invalid org", "org_url": url} + + if not self.is_valid_user_group(user_id, group_id): + return {"status": "Invalid user group", "group_id": group_id} failed_repos = [] for repo in repos: - result = self.add_frontend_repo(repo, user_id) + result = self.add_frontend_repo(repo, user_id, group_id, valid_group=True) if result["status"] != "Repo Added": failed_repos.append(repo) @@ -278,7 +318,7 @@ def add_cli_repo(self, repo_data: Dict[str, Any]): logger.warning(f"Invalid repo group id specified for {url}, skipping.") return - self.add_repo_to_user(repo_id, CLI_USER_ID) + self.add_repo_to_user_group(repo_id) def add_cli_org(self, org_name): """Add list of orgs and their repos to specified repo_groups From 75e275eb872ac49ca674bd715552e60f1171637e Mon Sep 17 00:00:00 2001 From: Andrew Brain Date: Mon, 19 Dec 2022 09:47:01 -0600 Subject: [PATCH 002/150] Add user group table Signed-off-by: Andrew Brain --- .../application/db/models/augur_operations.py | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/augur/application/db/models/augur_operations.py b/augur/application/db/models/augur_operations.py index a83d34b143..0af91d9d49 100644 --- a/augur/application/db/models/augur_operations.py +++ b/augur/application/db/models/augur_operations.py @@ -1,6 +1,7 @@ # coding: utf-8 from sqlalchemy import BigInteger, SmallInteger, Column, Index, Integer, String, Table, text, UniqueConstraint, Boolean, ForeignKey -from sqlalchemy.dialects.postgresql import TIMESTAMP +from sqlalchemy.dialects.postgresql import TIMESTAMP, UUID + from augur.application.db.models.base import Base @@ -191,6 +192,19 @@ class User(Base): ) +class UserGroup(Base): + id = Column(UUID, primary_key=True) + user_id = Column(Integer, + ForeignKey("augur_operations.users.user_id", name="user_group_user_id_fkey") + ) + name = Column(String, nullable=False) + __tablename__ = 'user_groups' + __table_args__ = ( + UniqueConstraint('user_g', name='user_group_unique'), + {"schema": "augur_operations"} + ) + + class UserRepo(Base): __tablename__ = "user_repos" @@ -200,10 +214,10 @@ class UserRepo(Base): } ) - user_id = Column( - ForeignKey("augur_operations.users.user_id"), primary_key=True, nullable=False + group_id = Column( + ForeignKey("augur_operations.user_groups.group_id", name="user_repo_group_id_fkey"), primary_key=True, nullable=False ) repo_id = Column( - ForeignKey("augur_data.repo.repo_id"), primary_key=True, nullable=False + ForeignKey("augur_data.repo.repo_id", name="user_repo_user_id_fkey"), primary_key=True, nullable=False ) From a7cd7fb47a98b1154814c7b4228dd8abf2532cbd Mon Sep 17 00:00:00 2001 From: Andrew Brain Date: Mon, 19 Dec 2022 09:49:36 -0600 Subject: [PATCH 003/150] Changes for user groups Signed-off-by: Andrew Brain --- augur/application/db/util.py | 4 +- ..._add_user_groups_table_and_update_user_.py | 85 +++++++++++++++++++ augur/util/repo_load_controller.py | 4 +- 3 files changed, 89 insertions(+), 4 deletions(-) create mode 100644 augur/application/schema/alembic/versions/2022-12-08_2_add_user_groups_table_and_update_user_.py diff --git a/augur/application/db/util.py b/augur/application/db/util.py index b310c68c10..3a5ed38e79 100644 --- a/augur/application/db/util.py +++ b/augur/application/db/util.py @@ -14,8 +14,8 @@ def catch_operational_error(func): time.sleep(240) try: return func() - except OperationalError: - pass + except OperationalError as e: + print(f"ERROR: {e}") attempts += 1 diff --git a/augur/application/schema/alembic/versions/2022-12-08_2_add_user_groups_table_and_update_user_.py b/augur/application/schema/alembic/versions/2022-12-08_2_add_user_groups_table_and_update_user_.py new file mode 100644 index 0000000000..f93f2b9f4b --- /dev/null +++ b/augur/application/schema/alembic/versions/2022-12-08_2_add_user_groups_table_and_update_user_.py @@ -0,0 +1,85 @@ +"""Add user_groups table and update user_repo + +Revision ID: 2 +Revises: 1 +Create Date: 2022-12-08 09:37:17.864281 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql +from augur.application.db.session import DatabaseSession +from augur.application.db.models.augur_operations import UserGroup, UserRepo +import uuid +import logging + +# revision identifiers, used by Alembic. +revision = '2' +down_revision = '1' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('user_groups', + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('group_id', postgresql.UUID(), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['augur_operations.users.user_id'], name='user_group_user_id_fkey'), + sa.PrimaryKeyConstraint('group_id'), + schema='augur_operations' + ) + + + + logger = logging.getLogger(__name__) + + user_group_id_mapping = {} + with DatabaseSession(logger) as session: + user_id_query = sa.sql.text("""SELECT * FROM user_repos;""") + user_groups = session.fetchall_data_from_sql_text(user_id_query) + for row in user_groups: + row.update({"group_id": uuid.uuid4(), "name": "default"}) + user_group_id_mapping.update({row["user_id"]: row["group_id"]}) + del row["repo_id"] + + user_repo_query = sa.sql.text("""SELECT * FROM user_repos;""") + user_repo_data = session.fetchall_data_from_sql_text(user_repo_query) + for row in user_repo_data: + row.update({"group_id": user_group_id_mapping[row["user_id"]]}) + del row["user_id"] + + + remove_data_from_user_repos_query = sa.sql.text("""DELETE FROM user_repos;""") + session.execute_sql(remove_data_from_user_repos_query) + + op.add_column('user_repos', sa.Column('group_id', postgresql.UUID(), nullable=False), schema='augur_operations') + op.drop_constraint('user_repos_user_id_fkey', 'user_repos', schema='augur_operations', type_='foreignkey') + op.drop_constraint('user_repos_repo_id_fkey', 'user_repos', schema='augur_operations', type_='foreignkey') + op.create_foreign_key('user_repo_group_id_fkey', 'user_repos', 'user_groups', ['group_id'], ['group_id'], source_schema='augur_operations', referent_schema='augur_operations') + op.create_foreign_key('user_repo_user_id_fkey', 'user_repos', 'repo', ['repo_id'], ['repo_id'], source_schema='augur_operations', referent_schema='augur_data') + op.drop_column('user_repos', 'user_id', schema='augur_operations') + + session.insert_data(user_groups, UserGroup, ["user_id", "group_id"]) + session.insert_data(user_groups, UserRepo, ["group_id", "repo_id"]) + + + + + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + # op.add_column('user_repos', sa.Column('user_id', sa.INTEGER(), autoincrement=False, nullable=False), schema='augur_operations') + # op.drop_constraint('user_repo_user_id_fkey', 'user_repos', schema='augur_operations', type_='foreignkey') + # op.drop_constraint('user_repo_group_id_fkey', 'user_repos', schema='augur_operations', type_='foreignkey') + # op.create_foreign_key('user_repos_repo_id_fkey', 'user_repos', 'repo', ['repo_id'], ['repo_id'], source_schema='augur_operations') + # op.create_foreign_key('user_repos_user_id_fkey', 'user_repos', 'users', ['user_id'], ['user_id'], source_schema='augur_operations', referent_schema='augur_operations') + # op.drop_column('user_repos', 'group_id', schema='augur_operations') + + pass + # op.drop_table('user_groups', schema='augur_operations') + # ### end Alembic commands ### diff --git a/augur/util/repo_load_controller.py b/augur/util/repo_load_controller.py index 387ec30908..f7d46cb272 100644 --- a/augur/util/repo_load_controller.py +++ b/augur/util/repo_load_controller.py @@ -184,7 +184,7 @@ def add_repo_to_user_group(self, repo_id, group_id=CLI_GROUP_ID): repo_user_group_unique = ["group_id", "repo_id"] return_columns = ["group_id", "repo_id"] - data = self.session.insert_data(repo_user_data, UserRepo, repo_user_unique, return_columns) + data = self.session.insert_data(repo_user_group_data, UserGroup, repo_user_group_unique, return_columns) if data[0]["group_id"] == group_id and data[0]["repo_id"] == repo_id: return True @@ -211,7 +211,7 @@ def get_user_groups(self, user_id): def get_user_group_repos(self, user_id, group_id): - repos = self.session.query(UserRepo).filter(UserGroup.user_id == user_id, UserGroup.group_id = group_id).all() + repos = self.session.query(UserRepo).filter(UserGroup.user_id == user_id, UserGroup.group_id == group_id).all() return [repo["repo_id"] for repo in repos] From 527394480f62f347a739fc63d3d1c0f4829b3055 Mon Sep 17 00:00:00 2001 From: Andrew Brain Date: Mon, 19 Dec 2022 11:25:41 -0600 Subject: [PATCH 004/150] Start working on converting old dbs to new version Signed-off-by: Andrew Brain --- .../application/db/models/augur_operations.py | 6 +- ..._add_user_groups_table_and_update_user_.py | 85 ----------------- ...-19_632bd5da0e79_added_user_group_table.py | 93 +++++++++++++++++++ 3 files changed, 96 insertions(+), 88 deletions(-) delete mode 100644 augur/application/schema/alembic/versions/2022-12-08_2_add_user_groups_table_and_update_user_.py create mode 100644 augur/application/schema/alembic/versions/2022-12-19_632bd5da0e79_added_user_group_table.py diff --git a/augur/application/db/models/augur_operations.py b/augur/application/db/models/augur_operations.py index 0af91d9d49..d007ce93e0 100644 --- a/augur/application/db/models/augur_operations.py +++ b/augur/application/db/models/augur_operations.py @@ -193,14 +193,14 @@ class User(Base): class UserGroup(Base): - id = Column(UUID, primary_key=True) + user_group_id = Column(BigInteger, primary_key=True) user_id = Column(Integer, ForeignKey("augur_operations.users.user_id", name="user_group_user_id_fkey") ) name = Column(String, nullable=False) __tablename__ = 'user_groups' __table_args__ = ( - UniqueConstraint('user_g', name='user_group_unique'), + UniqueConstraint('user_id', 'name', name='user_group_unique'), {"schema": "augur_operations"} ) @@ -215,7 +215,7 @@ class UserRepo(Base): ) group_id = Column( - ForeignKey("augur_operations.user_groups.group_id", name="user_repo_group_id_fkey"), primary_key=True, nullable=False + ForeignKey("augur_operations.user_groups.user_group_id", name="user_repo_group_id_fkey"), primary_key=True, nullable=False ) repo_id = Column( ForeignKey("augur_data.repo.repo_id", name="user_repo_user_id_fkey"), primary_key=True, nullable=False diff --git a/augur/application/schema/alembic/versions/2022-12-08_2_add_user_groups_table_and_update_user_.py b/augur/application/schema/alembic/versions/2022-12-08_2_add_user_groups_table_and_update_user_.py deleted file mode 100644 index f93f2b9f4b..0000000000 --- a/augur/application/schema/alembic/versions/2022-12-08_2_add_user_groups_table_and_update_user_.py +++ /dev/null @@ -1,85 +0,0 @@ -"""Add user_groups table and update user_repo - -Revision ID: 2 -Revises: 1 -Create Date: 2022-12-08 09:37:17.864281 - -""" -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import postgresql -from augur.application.db.session import DatabaseSession -from augur.application.db.models.augur_operations import UserGroup, UserRepo -import uuid -import logging - -# revision identifiers, used by Alembic. -revision = '2' -down_revision = '1' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('user_groups', - sa.Column('user_id', sa.Integer(), nullable=False), - sa.Column('group_id', postgresql.UUID(), nullable=False), - sa.Column('name', sa.String(), nullable=False), - sa.ForeignKeyConstraint(['user_id'], ['augur_operations.users.user_id'], name='user_group_user_id_fkey'), - sa.PrimaryKeyConstraint('group_id'), - schema='augur_operations' - ) - - - - logger = logging.getLogger(__name__) - - user_group_id_mapping = {} - with DatabaseSession(logger) as session: - user_id_query = sa.sql.text("""SELECT * FROM user_repos;""") - user_groups = session.fetchall_data_from_sql_text(user_id_query) - for row in user_groups: - row.update({"group_id": uuid.uuid4(), "name": "default"}) - user_group_id_mapping.update({row["user_id"]: row["group_id"]}) - del row["repo_id"] - - user_repo_query = sa.sql.text("""SELECT * FROM user_repos;""") - user_repo_data = session.fetchall_data_from_sql_text(user_repo_query) - for row in user_repo_data: - row.update({"group_id": user_group_id_mapping[row["user_id"]]}) - del row["user_id"] - - - remove_data_from_user_repos_query = sa.sql.text("""DELETE FROM user_repos;""") - session.execute_sql(remove_data_from_user_repos_query) - - op.add_column('user_repos', sa.Column('group_id', postgresql.UUID(), nullable=False), schema='augur_operations') - op.drop_constraint('user_repos_user_id_fkey', 'user_repos', schema='augur_operations', type_='foreignkey') - op.drop_constraint('user_repos_repo_id_fkey', 'user_repos', schema='augur_operations', type_='foreignkey') - op.create_foreign_key('user_repo_group_id_fkey', 'user_repos', 'user_groups', ['group_id'], ['group_id'], source_schema='augur_operations', referent_schema='augur_operations') - op.create_foreign_key('user_repo_user_id_fkey', 'user_repos', 'repo', ['repo_id'], ['repo_id'], source_schema='augur_operations', referent_schema='augur_data') - op.drop_column('user_repos', 'user_id', schema='augur_operations') - - session.insert_data(user_groups, UserGroup, ["user_id", "group_id"]) - session.insert_data(user_groups, UserRepo, ["group_id", "repo_id"]) - - - - - - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - # op.add_column('user_repos', sa.Column('user_id', sa.INTEGER(), autoincrement=False, nullable=False), schema='augur_operations') - # op.drop_constraint('user_repo_user_id_fkey', 'user_repos', schema='augur_operations', type_='foreignkey') - # op.drop_constraint('user_repo_group_id_fkey', 'user_repos', schema='augur_operations', type_='foreignkey') - # op.create_foreign_key('user_repos_repo_id_fkey', 'user_repos', 'repo', ['repo_id'], ['repo_id'], source_schema='augur_operations') - # op.create_foreign_key('user_repos_user_id_fkey', 'user_repos', 'users', ['user_id'], ['user_id'], source_schema='augur_operations', referent_schema='augur_operations') - # op.drop_column('user_repos', 'group_id', schema='augur_operations') - - pass - # op.drop_table('user_groups', schema='augur_operations') - # ### end Alembic commands ### diff --git a/augur/application/schema/alembic/versions/2022-12-19_632bd5da0e79_added_user_group_table.py b/augur/application/schema/alembic/versions/2022-12-19_632bd5da0e79_added_user_group_table.py new file mode 100644 index 0000000000..38c69a5216 --- /dev/null +++ b/augur/application/schema/alembic/versions/2022-12-19_632bd5da0e79_added_user_group_table.py @@ -0,0 +1,93 @@ +"""Added user group table + +Revision ID: 632bd5da0e79 +Revises: 1 +Create Date: 2022-12-19 11:00:37.509132 + +""" +import logging + +from alembic import op +import sqlalchemy as sa +from augur.application.db.session import DatabaseSession + + + +# revision identifiers, used by Alembic. +revision = '632bd5da0e79' +down_revision = '1' +branch_labels = None +depends_on = None + + +def upgrade(): + + + + + + + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('user_groups', + sa.Column('user_group_id', sa.BigInteger(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=True), + sa.Column('name', sa.String(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['augur_operations.users.user_id'], name='user_group_user_id_fkey'), + sa.PrimaryKeyConstraint('user_group_id'), + sa.UniqueConstraint('user_id', 'name', name='user_group_unique'), + schema='augur_operations' + ) + + logger = logging.getLogger(__name__) + + + with DatabaseSession(logger) as session: + user_id_query = sa.sql.text("""SELECT DISTINCT(user_id) FROM user_repos;""") + user_groups = session.fetchall_data_from_sql_text(user_id_query) + for row in user_groups: + row.update({"name": "default"}) + del row["repo_id"] + + result = session.insert_data(user_groups, UserGroup, ["user_id", "name"], return_columns=["group_id", "user_id"]) + + + user_group_id_mapping = {} + for row in result: + user_group_id_mapping[row["user_id"]] = row["group_id"] + + + user_repo_query = sa.sql.text("""SELECT * FROM user_repos;""") + user_repo_data = session.fetchall_data_from_sql_text(user_repo_query) + for row in user_repo_data: + row.update({"group_id": user_group_id_mapping[row["user_id"]]}) + del row["user_id"] + + + + # remove data from table before modifiying it + remove_data_from_user_repos_query = sa.sql.text("""DELETE FROM user_repos;""") + session.execute_sql(remove_data_from_user_repos_query) + + + op.add_column('user_repos', sa.Column('group_id', sa.BigInteger(), nullable=False), schema='augur_operations') + op.drop_constraint('user_repos_repo_id_fkey', 'user_repos', schema='augur_operations', type_='foreignkey') + op.drop_constraint('user_repos_user_id_fkey', 'user_repos', schema='augur_operations', type_='foreignkey') + op.create_foreign_key('user_repo_user_id_fkey', 'user_repos', 'repo', ['repo_id'], ['repo_id'], source_schema='augur_operations', referent_schema='augur_data') + op.create_foreign_key('user_repo_group_id_fkey', 'user_repos', 'user_groups', ['group_id'], ['user_group_id'], source_schema='augur_operations', referent_schema='augur_operations') + op.drop_column('user_repos', 'user_id', schema='augur_operations') + + + session.insert_data(user_repo_data, UserRepo, ["group_id", "repo_id"]) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('user_repos', sa.Column('user_id', sa.INTEGER(), autoincrement=False, nullable=False), schema='augur_operations') + op.drop_constraint('user_repo_group_id_fkey', 'user_repos', schema='augur_operations', type_='foreignkey') + op.drop_constraint('user_repo_user_id_fkey', 'user_repos', schema='augur_operations', type_='foreignkey') + op.create_foreign_key('user_repos_user_id_fkey', 'user_repos', 'users', ['user_id'], ['user_id'], source_schema='augur_operations', referent_schema='augur_operations') + op.create_foreign_key('user_repos_repo_id_fkey', 'user_repos', 'repo', ['repo_id'], ['repo_id'], source_schema='augur_operations') + op.drop_column('user_repos', 'group_id', schema='augur_operations') + op.drop_table('user_groups', schema='augur_operations') + # ### end Alembic commands ### From 4005390055e19840e7c7c5d8a3aab3717c6905bf Mon Sep 17 00:00:00 2001 From: Andrew Brain Date: Tue, 20 Dec 2022 09:22:16 -0600 Subject: [PATCH 005/150] Add script to upgrade database Signed-off-by: Andrew Brain --- .../application/db/models/augur_operations.py | 4 ++-- ...p_table.py => 2_added_user_group_table.py} | 22 ++++++++----------- 2 files changed, 11 insertions(+), 15 deletions(-) rename augur/application/schema/alembic/versions/{2022-12-19_632bd5da0e79_added_user_group_table.py => 2_added_user_group_table.py} (87%) diff --git a/augur/application/db/models/augur_operations.py b/augur/application/db/models/augur_operations.py index d007ce93e0..51002a84c9 100644 --- a/augur/application/db/models/augur_operations.py +++ b/augur/application/db/models/augur_operations.py @@ -193,7 +193,7 @@ class User(Base): class UserGroup(Base): - user_group_id = Column(BigInteger, primary_key=True) + group_id = Column(BigInteger, primary_key=True) user_id = Column(Integer, ForeignKey("augur_operations.users.user_id", name="user_group_user_id_fkey") ) @@ -215,7 +215,7 @@ class UserRepo(Base): ) group_id = Column( - ForeignKey("augur_operations.user_groups.user_group_id", name="user_repo_group_id_fkey"), primary_key=True, nullable=False + ForeignKey("augur_operations.user_groups.group_id", name="user_repo_group_id_fkey"), primary_key=True, nullable=False ) repo_id = Column( ForeignKey("augur_data.repo.repo_id", name="user_repo_user_id_fkey"), primary_key=True, nullable=False diff --git a/augur/application/schema/alembic/versions/2022-12-19_632bd5da0e79_added_user_group_table.py b/augur/application/schema/alembic/versions/2_added_user_group_table.py similarity index 87% rename from augur/application/schema/alembic/versions/2022-12-19_632bd5da0e79_added_user_group_table.py rename to augur/application/schema/alembic/versions/2_added_user_group_table.py index 38c69a5216..2be929101e 100644 --- a/augur/application/schema/alembic/versions/2022-12-19_632bd5da0e79_added_user_group_table.py +++ b/augur/application/schema/alembic/versions/2_added_user_group_table.py @@ -1,6 +1,6 @@ """Added user group table -Revision ID: 632bd5da0e79 +Revision ID: 2 Revises: 1 Create Date: 2022-12-19 11:00:37.509132 @@ -10,11 +10,12 @@ from alembic import op import sqlalchemy as sa from augur.application.db.session import DatabaseSession +from augur.application.db.models.augur_operations import UserGroup, UserRepo # revision identifiers, used by Alembic. -revision = '632bd5da0e79' +revision = '2' down_revision = '1' branch_labels = None depends_on = None @@ -22,18 +23,13 @@ def upgrade(): - - - - - - # ### commands auto generated by Alembic - please adjust! ### + ### commands auto generated by Alembic - please adjust! ### op.create_table('user_groups', - sa.Column('user_group_id', sa.BigInteger(), nullable=False), + sa.Column('group_id', sa.BigInteger(), nullable=False), sa.Column('user_id', sa.Integer(), nullable=True), sa.Column('name', sa.String(), nullable=False), sa.ForeignKeyConstraint(['user_id'], ['augur_operations.users.user_id'], name='user_group_user_id_fkey'), - sa.PrimaryKeyConstraint('user_group_id'), + sa.PrimaryKeyConstraint('group_id'), sa.UniqueConstraint('user_id', 'name', name='user_group_unique'), schema='augur_operations' ) @@ -46,7 +42,6 @@ def upgrade(): user_groups = session.fetchall_data_from_sql_text(user_id_query) for row in user_groups: row.update({"name": "default"}) - del row["repo_id"] result = session.insert_data(user_groups, UserGroup, ["user_id", "name"], return_columns=["group_id", "user_id"]) @@ -67,14 +62,15 @@ def upgrade(): # remove data from table before modifiying it remove_data_from_user_repos_query = sa.sql.text("""DELETE FROM user_repos;""") session.execute_sql(remove_data_from_user_repos_query) - + op.add_column('user_repos', sa.Column('group_id', sa.BigInteger(), nullable=False), schema='augur_operations') op.drop_constraint('user_repos_repo_id_fkey', 'user_repos', schema='augur_operations', type_='foreignkey') op.drop_constraint('user_repos_user_id_fkey', 'user_repos', schema='augur_operations', type_='foreignkey') op.create_foreign_key('user_repo_user_id_fkey', 'user_repos', 'repo', ['repo_id'], ['repo_id'], source_schema='augur_operations', referent_schema='augur_data') - op.create_foreign_key('user_repo_group_id_fkey', 'user_repos', 'user_groups', ['group_id'], ['user_group_id'], source_schema='augur_operations', referent_schema='augur_operations') + op.create_foreign_key('user_repo_group_id_fkey', 'user_repos', 'user_groups', ['group_id'], ['group_id'], source_schema='augur_operations', referent_schema='augur_operations') op.drop_column('user_repos', 'user_id', schema='augur_operations') + op.create_primary_key('user_repos_pk', 'user_repos', ['repo_id', 'group_id']) session.insert_data(user_repo_data, UserRepo, ["group_id", "repo_id"]) From 7b4556955050e2e4c6c03b50646955c209ff64a9 Mon Sep 17 00:00:00 2001 From: Andrew Brain Date: Tue, 20 Dec 2022 14:24:43 -0600 Subject: [PATCH 006/150] Fix up downgrade and upgrade script Signed-off-by: Andrew Brain --- .../versions/2_added_user_group_table.py | 181 +++++++++++++----- 1 file changed, 137 insertions(+), 44 deletions(-) diff --git a/augur/application/schema/alembic/versions/2_added_user_group_table.py b/augur/application/schema/alembic/versions/2_added_user_group_table.py index 2be929101e..092f7c287f 100644 --- a/augur/application/schema/alembic/versions/2_added_user_group_table.py +++ b/augur/application/schema/alembic/versions/2_added_user_group_table.py @@ -20,70 +20,163 @@ branch_labels = None depends_on = None +logger = logging.getLogger(__name__) def upgrade(): - ### commands auto generated by Alembic - please adjust! ### - op.create_table('user_groups', - sa.Column('group_id', sa.BigInteger(), nullable=False), - sa.Column('user_id', sa.Integer(), nullable=True), - sa.Column('name', sa.String(), nullable=False), - sa.ForeignKeyConstraint(['user_id'], ['augur_operations.users.user_id'], name='user_group_user_id_fkey'), - sa.PrimaryKeyConstraint('group_id'), - sa.UniqueConstraint('user_id', 'name', name='user_group_unique'), - schema='augur_operations' - ) + with DatabaseSession(logger) as session: - logger = logging.getLogger(__name__) + print("Creating user groups table") + create_user_groups_table = """ + CREATE TABLE "augur_operations"."user_groups" ( + "group_id" BIGSERIAL NOT NULL, + "user_id" int4 NOT NULL, + "name" varchar COLLATE "pg_catalog"."default" NOT NULL, + PRIMARY KEY ("group_id"), + FOREIGN KEY ("user_id") REFERENCES "augur_operations"."users" ("user_id") ON DELETE NO ACTION ON UPDATE NO ACTION, + UNIQUE ("user_id", "name") + ); - - with DatabaseSession(logger) as session: + + ALTER TABLE "augur_operations"."user_groups" + OWNER TO "augur"; + """ + + session.execute_sql(sa.sql.text(create_user_groups_table)) + + + user_repos = [] + + print("Creating user groups") + # create user group for all the users that have repos user_id_query = sa.sql.text("""SELECT DISTINCT(user_id) FROM user_repos;""") user_groups = session.fetchall_data_from_sql_text(user_id_query) - for row in user_groups: - row.update({"name": "default"}) + if user_groups: + + result = [] + for row in user_groups: + + user_id = row["user_id"] + + user_group_insert = sa.sql.text(f"""INSERT INTO "augur_operations"."user_groups" ("user_id", "name") VALUES ({user_id}, 'default') RETURNING group_id, user_id;""") + result.append(session.fetchall_data_from_sql_text(user_group_insert)[0]) + + user_group_id_mapping = {} + for row in result: + user_group_id_mapping[row["user_id"]] = row["group_id"] + + + print("Getting users repos") + user_repo_query = sa.sql.text("""SELECT * FROM user_repos;""") + user_repo_data = session.fetchall_data_from_sql_text(user_repo_query) + for row in user_repo_data: + row.update({"group_id": user_group_id_mapping[row["user_id"]]}) + del row["user_id"] + user_repos.extend(user_repo_data) + + print("Removing data from user repos table") + # remove data from table before modifiying it + remove_data_from_user_repos_query = sa.sql.text("""DELETE FROM user_repos;""") + session.execute_sql(remove_data_from_user_repos_query) + + + table_changes = """ + ALTER TABLE user_repos + ADD COLUMN group_id INT, + ADD CONSTRAINT user_repos_group_id_fkey FOREIGN KEY (group_id) REFERENCES user_groups(group_id), + DROP COLUMN user_id, + ADD PRIMARY KEY (group_id, repo_id); + """ + + session.execute_sql(sa.sql.text(table_changes)) + + print(user_repos) + + print("Inserting data into user repos table") + for data in user_repos: + + group_id = data["group_id"] + repo_id = data["repo_id"] + + user_repo_insert = sa.sql.text(f"""INSERT INTO "augur_operations"."user_repos" ("group_id", "repo_id") VALUES ({group_id}, {repo_id});""") + result = session.execute_sql(user_repo_insert) + # ### end Alembic commands ### - result = session.insert_data(user_groups, UserGroup, ["user_id", "name"], return_columns=["group_id", "user_id"]) +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### - user_group_id_mapping = {} - for row in result: - user_group_id_mapping[row["user_id"]] = row["group_id"] - + print("Downgrade") - user_repo_query = sa.sql.text("""SELECT * FROM user_repos;""") - user_repo_data = session.fetchall_data_from_sql_text(user_repo_query) - for row in user_repo_data: - row.update({"group_id": user_group_id_mapping[row["user_id"]]}) - del row["user_id"] + user_group_ids = {} + group_repo_ids = {} + with DatabaseSession(logger) as session: + user_id_query = sa.sql.text("""SELECT * FROM user_groups;""") + user_groups = session.fetchall_data_from_sql_text(user_id_query) + for row in user_groups: + try: + user_group_ids[row["user_id"]].append(row["group_id"]) + except KeyError: + user_group_ids[row["user_id"]] = [row["group_id"]] + group_id_query = sa.sql.text("""SELECT * FROM user_repos;""") + group_repo_id_result = session.fetchall_data_from_sql_text(group_id_query) + for row in group_repo_id_result: + try: + group_repo_ids[row["group_id"]].append(row["repo_id"]) + except KeyError: + group_repo_ids[row["group_id"]] = [row["repo_id"]] - # remove data from table before modifiying it remove_data_from_user_repos_query = sa.sql.text("""DELETE FROM user_repos;""") session.execute_sql(remove_data_from_user_repos_query) - op.add_column('user_repos', sa.Column('group_id', sa.BigInteger(), nullable=False), schema='augur_operations') - op.drop_constraint('user_repos_repo_id_fkey', 'user_repos', schema='augur_operations', type_='foreignkey') - op.drop_constraint('user_repos_user_id_fkey', 'user_repos', schema='augur_operations', type_='foreignkey') - op.create_foreign_key('user_repo_user_id_fkey', 'user_repos', 'repo', ['repo_id'], ['repo_id'], source_schema='augur_operations', referent_schema='augur_data') - op.create_foreign_key('user_repo_group_id_fkey', 'user_repos', 'user_groups', ['group_id'], ['group_id'], source_schema='augur_operations', referent_schema='augur_operations') - op.drop_column('user_repos', 'user_id', schema='augur_operations') - op.create_primary_key('user_repos_pk', 'user_repos', ['repo_id', 'group_id']) + table_changes = """ + ALTER TABLE user_repos + ADD COLUMN user_id INT, + ADD CONSTRAINT user_repos_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(user_id), + DROP COLUMN group_id, + ADD PRIMARY KEY (user_id, repo_id); + DROP TABLE user_groups; + """ + session.execute_sql(sa.sql.text(table_changes)) - session.insert_data(user_repo_data, UserRepo, ["group_id", "repo_id"]) - # ### end Alembic commands ### + print("user group ids") + print(user_group_ids) + + print("Group repo ids") + print(group_repo_ids) + + for user_id, group_ids in user_group_ids.items(): + + print(f"User id: {user_id}") + print(f"Group ids: {group_ids}") + + repos = [] + for group_id in group_ids: + try: + repos.extend(group_repo_ids[group_id]) + except KeyError: + continue + + print(f"User: {user_id} Repos: {repos}") + + query_text_array = ["""INSERT INTO "augur_operations"."user_repos" ("repo_id", "user_id") VALUES """] + for i, repo_id in enumerate(repos): + query_text_array.append(f"({repo_id}, {user_id})") + + delimiter = ";" if i == len(repos) -1 else "," + + query_text_array.append(delimiter) + + + query_text = "".join(query_text_array) + + print(query_text) + + session.execute_sql(sa.sql.text(query_text)) -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.add_column('user_repos', sa.Column('user_id', sa.INTEGER(), autoincrement=False, nullable=False), schema='augur_operations') - op.drop_constraint('user_repo_group_id_fkey', 'user_repos', schema='augur_operations', type_='foreignkey') - op.drop_constraint('user_repo_user_id_fkey', 'user_repos', schema='augur_operations', type_='foreignkey') - op.create_foreign_key('user_repos_user_id_fkey', 'user_repos', 'users', ['user_id'], ['user_id'], source_schema='augur_operations', referent_schema='augur_operations') - op.create_foreign_key('user_repos_repo_id_fkey', 'user_repos', 'repo', ['repo_id'], ['repo_id'], source_schema='augur_operations') - op.drop_column('user_repos', 'group_id', schema='augur_operations') - op.drop_table('user_groups', schema='augur_operations') # ### end Alembic commands ### From bc8e38e61218d409dcd35e8b3b54a2221a7be7bc Mon Sep 17 00:00:00 2001 From: Andrew Brain Date: Tue, 20 Dec 2022 14:30:10 -0600 Subject: [PATCH 007/150] Remove prints from script Signed-off-by: Andrew Brain --- .../versions/2_added_user_group_table.py | 22 ------------------- 1 file changed, 22 deletions(-) diff --git a/augur/application/schema/alembic/versions/2_added_user_group_table.py b/augur/application/schema/alembic/versions/2_added_user_group_table.py index 092f7c287f..460b41aa1c 100644 --- a/augur/application/schema/alembic/versions/2_added_user_group_table.py +++ b/augur/application/schema/alembic/versions/2_added_user_group_table.py @@ -26,7 +26,6 @@ def upgrade(): with DatabaseSession(logger) as session: - print("Creating user groups table") create_user_groups_table = """ CREATE TABLE "augur_operations"."user_groups" ( "group_id" BIGSERIAL NOT NULL, @@ -47,7 +46,6 @@ def upgrade(): user_repos = [] - print("Creating user groups") # create user group for all the users that have repos user_id_query = sa.sql.text("""SELECT DISTINCT(user_id) FROM user_repos;""") user_groups = session.fetchall_data_from_sql_text(user_id_query) @@ -66,7 +64,6 @@ def upgrade(): user_group_id_mapping[row["user_id"]] = row["group_id"] - print("Getting users repos") user_repo_query = sa.sql.text("""SELECT * FROM user_repos;""") user_repo_data = session.fetchall_data_from_sql_text(user_repo_query) for row in user_repo_data: @@ -74,7 +71,6 @@ def upgrade(): del row["user_id"] user_repos.extend(user_repo_data) - print("Removing data from user repos table") # remove data from table before modifiying it remove_data_from_user_repos_query = sa.sql.text("""DELETE FROM user_repos;""") session.execute_sql(remove_data_from_user_repos_query) @@ -90,9 +86,6 @@ def upgrade(): session.execute_sql(sa.sql.text(table_changes)) - print(user_repos) - - print("Inserting data into user repos table") for data in user_repos: group_id = data["group_id"] @@ -106,7 +99,6 @@ def upgrade(): def downgrade(): # ### commands auto generated by Alembic - please adjust! ### - print("Downgrade") user_group_ids = {} group_repo_ids = {} @@ -143,18 +135,8 @@ def downgrade(): session.execute_sql(sa.sql.text(table_changes)) - - print("user group ids") - print(user_group_ids) - - print("Group repo ids") - print(group_repo_ids) - for user_id, group_ids in user_group_ids.items(): - print(f"User id: {user_id}") - print(f"Group ids: {group_ids}") - repos = [] for group_id in group_ids: try: @@ -162,8 +144,6 @@ def downgrade(): except KeyError: continue - print(f"User: {user_id} Repos: {repos}") - query_text_array = ["""INSERT INTO "augur_operations"."user_repos" ("repo_id", "user_id") VALUES """] for i, repo_id in enumerate(repos): query_text_array.append(f"({repo_id}, {user_id})") @@ -175,8 +155,6 @@ def downgrade(): query_text = "".join(query_text_array) - print(query_text) - session.execute_sql(sa.sql.text(query_text)) # ### end Alembic commands ### From 9c6ad2c5d2857305ca2dd45b07812e392cc328bc Mon Sep 17 00:00:00 2001 From: Andrew Brain Date: Tue, 20 Dec 2022 14:35:53 -0600 Subject: [PATCH 008/150] Fixes to repo insertion methods Signed-off-by: Andrew Brain --- augur/util/repo_load_controller.py | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/augur/util/repo_load_controller.py b/augur/util/repo_load_controller.py index f7d46cb272..c2a87957a0 100644 --- a/augur/util/repo_load_controller.py +++ b/augur/util/repo_load_controller.py @@ -184,27 +184,22 @@ def add_repo_to_user_group(self, repo_id, group_id=CLI_GROUP_ID): repo_user_group_unique = ["group_id", "repo_id"] return_columns = ["group_id", "repo_id"] - data = self.session.insert_data(repo_user_group_data, UserGroup, repo_user_group_unique, return_columns) + data = self.session.insert_data(repo_user_group_data, UserRepo, repo_user_group_unique, return_columns) - if data[0]["group_id"] == group_id and data[0]["repo_id"] == repo_id: - return True - - return False + return data[0]["group_id"] == group_id and data[0]["repo_id"] == repo_id def add_user_group(self, user_id, group_name): user_group_data = { - "group_name": group_id, - "user_id": repo_id + "name": group_name, + "user_id": user_id } - # TODO Add exception for duplicate groups - group_obj = UserGroup(**user_group_data) - self.session.add(group_obj) - self.session.commit() - - return True + result = session.insert_data(user_group_data, UserGroup, ["name", "user_id"], return_columns=["group_id"]) + + return result is not None + def get_user_groups(self, user_id): return self.session.query(UserGroup).filter(UserGroup.user_id == user_id).all() From e63c27ede7dbd7ce6721ea2dd96787a0a3f91414 Mon Sep 17 00:00:00 2001 From: Andrew Brain Date: Tue, 20 Dec 2022 16:21:23 -0600 Subject: [PATCH 009/150] First run of adding repos to groups Signed-off-by: Andrew Brain --- augur/api/routes/user.py | 35 ++++++++++++++++++++++--- augur/application/db/models/__init__.py | 3 ++- augur/util/repo_load_controller.py | 14 +++++----- 3 files changed, 42 insertions(+), 10 deletions(-) diff --git a/augur/api/routes/user.py b/augur/api/routes/user.py index 62539be357..896166e6cf 100644 --- a/augur/api/routes/user.py +++ b/augur/api/routes/user.py @@ -16,7 +16,7 @@ from augur.util.repo_load_controller import RepoLoadController -from augur.application.db.models import User, UserRepo +from augur.application.db.models import User, UserRepo, UserGroup from augur.application.config import get_development_flag logger = logging.getLogger(__name__) development = get_development_flag() @@ -206,19 +206,48 @@ def add_user_repo(): username = request.args.get("username") repo = request.args.get("repo_url") + group_name = request.args.get("group_name") with GithubTaskSession(logger) as session: - if username is None: + if username is None or repo is None or group_name is None: return jsonify({"status": "Missing argument"}), 400 user = session.query(User).filter( User.login_name == username).first() if user is None: return jsonify({"status": "User does not exist"}) + user_group = session.query(UserGroup).filter(UserGroup.user_id == user.user_id, UserGroup.name == group_name).first() + if user_group is None: + return jsonify({"status": "User group does not exists"}) + + repo_load_controller = RepoLoadController(gh_session=session) + + result = repo_load_controller.add_frontend_repo(repo, user.user_id, user_group.group_id) + + return jsonify(result) + + + @server.app.route(f"/{AUGUR_API_VERSION}/user/add_group", methods=['GET', 'POST']) + def add_user_group(): + if not development and not request.is_secure: + return generate_upgrade_request() + + username = request.args.get("username") + group_name = request.args.get("group_name") + + with GithubTaskSession(logger) as session: + + if username is None or group_name is None: + return jsonify({"status": "Missing argument"}), 400 + + user = session.query(User).filter(User.login_name == username).first() + if user is None: + return jsonify({"status": "User does not exist"}) + repo_load_controller = RepoLoadController(gh_session=session) - result = repo_load_controller.add_frontend_repo(repo, user.user_id) + result = repo_load_controller.add_user_group(user.user_id, group_name) return jsonify(result) diff --git a/augur/application/db/models/__init__.py b/augur/application/db/models/__init__.py index ab9c17953b..f51e768ed9 100644 --- a/augur/application/db/models/__init__.py +++ b/augur/application/db/models/__init__.py @@ -99,5 +99,6 @@ WorkerSettingsFacade, Config, User, - UserRepo + UserRepo, + UserGroup ) diff --git a/augur/util/repo_load_controller.py b/augur/util/repo_load_controller.py index c2a87957a0..efc619285f 100644 --- a/augur/util/repo_load_controller.py +++ b/augur/util/repo_load_controller.py @@ -10,7 +10,7 @@ from augur.tasks.github.util.github_paginator import GithubPaginator from augur.tasks.github.util.github_task_session import GithubTaskSession from augur.application.db.session import DatabaseSession -from augur.application.db.models import Repo, UserRepo, RepoGroup +from augur.application.db.models import Repo, UserRepo, RepoGroup, UserGroup from augur.application.db.util import execute_session_query @@ -73,7 +73,7 @@ def is_valid_repo(self, url: str) -> bool: def is_valid_user_group(self, user_id, group_id) -> bool: try: - self.session.query(UserRepo).filter(UserRepo.user_id == user_id, UserRepo.group_id == group_id).one() + self.session.query(UserRepo).filter(UserGroup.user_id == user_id, UserGroup.group_id == group_id).one() return True except s.orm.exc.NoResultFound: return False @@ -195,10 +195,12 @@ def add_user_group(self, user_id, group_name): "user_id": user_id } + result = self.session.insert_data(user_group_data, UserGroup, ["name", "user_id"], return_columns=["group_id"]) - result = session.insert_data(user_group_data, UserGroup, ["name", "user_id"], return_columns=["group_id"]) - - return result is not None + if result: + return {"status": "Group created"} + else: + return {"status": "Group already exists"} def get_user_groups(self, user_id): @@ -229,7 +231,7 @@ def add_frontend_repo(self, url: List[str], user_id: int, group_id: int, valid_g if not valid_group and not self.is_valid_user_group(user_id, group_id): return {"status": "Invalid user group", "group_id": group_id} - repo_id = self.add_repo_row(url, DEFAULT_REPO_GROUP_ID, "Frontend") + repo_id = self.add_repo_row(url, DEFAULT_REPO_GROUP_IDS[0], "Frontend") if not repo_id: return {"status": "Repo insertion failed", "repo_url": url} From 84f766815aa58da72d59e77c6505794be1fb9b98 Mon Sep 17 00:00:00 2001 From: Andrew Brain Date: Tue, 20 Dec 2022 16:29:22 -0600 Subject: [PATCH 010/150] Match the group id data types Signed-off-by: Andrew Brain --- .../schema/alembic/versions/2_added_user_group_table.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/augur/application/schema/alembic/versions/2_added_user_group_table.py b/augur/application/schema/alembic/versions/2_added_user_group_table.py index 460b41aa1c..4334d94a82 100644 --- a/augur/application/schema/alembic/versions/2_added_user_group_table.py +++ b/augur/application/schema/alembic/versions/2_added_user_group_table.py @@ -78,7 +78,7 @@ def upgrade(): table_changes = """ ALTER TABLE user_repos - ADD COLUMN group_id INT, + ADD COLUMN group_id BIGINT, ADD CONSTRAINT user_repos_group_id_fkey FOREIGN KEY (group_id) REFERENCES user_groups(group_id), DROP COLUMN user_id, ADD PRIMARY KEY (group_id, repo_id); From 43f3eb065262a2c7487394692e60e4a3b64c98be Mon Sep 17 00:00:00 2001 From: Andrew Brain Date: Wed, 21 Dec 2022 15:25:50 -0600 Subject: [PATCH 011/150] Major improvements to user group functionality Signed-off-by: Andrew Brain --- augur/api/routes/user.py | 65 ++++++++-- augur/application/db/models/augur_data.py | 1 + .../application/db/models/augur_operations.py | 9 ++ .../versions/2_added_user_group_table.py | 20 ++- augur/util/repo_load_controller.py | 109 +++++++++++++---- .../test_repo_load_controller.py | 114 ++++++++++++++---- 6 files changed, 257 insertions(+), 61 deletions(-) diff --git a/augur/api/routes/user.py b/augur/api/routes/user.py index 896166e6cf..b228ccfdf3 100644 --- a/augur/api/routes/user.py +++ b/augur/api/routes/user.py @@ -217,13 +217,9 @@ def add_user_repo(): if user is None: return jsonify({"status": "User does not exist"}) - user_group = session.query(UserGroup).filter(UserGroup.user_id == user.user_id, UserGroup.name == group_name).first() - if user_group is None: - return jsonify({"status": "User group does not exists"}) - repo_load_controller = RepoLoadController(gh_session=session) - result = repo_load_controller.add_frontend_repo(repo, user.user_id, user_group.group_id) + result = repo_load_controller.add_frontend_repo(repo, user.user_id, group_name) return jsonify(result) @@ -236,6 +232,9 @@ def add_user_group(): username = request.args.get("username") group_name = request.args.get("group_name") + if group_name == "default": + return jsonify({"status": "Reserved Group Name"}) + with GithubTaskSession(logger) as session: if username is None or group_name is None: @@ -251,6 +250,31 @@ def add_user_group(): return jsonify(result) + @server.app.route(f"/{AUGUR_API_VERSION}/user/remove_group", methods=['GET', 'POST']) + def remove_user_group(): + if not development and not request.is_secure: + return generate_upgrade_request() + + username = request.args.get("username") + group_name = request.args.get("group_name") + + with GithubTaskSession(logger) as session: + + if username is None or group_name is None + return jsonify({"status": "Missing argument"}), 400 + + user = session.query(User).filter(User.login_name == username).first() + if user is None: + return jsonify({"status": "User does not exist"}) + + repo_load_controller = RepoLoadController(gh_session=session) + + result = repo_load_controller.remove_user_group(user.user_id, group_name) + + return jsonify(result) + + + @server.app.route(f"/{AUGUR_API_VERSION}/user/add_org", methods=['GET', 'POST']) def add_user_org(): @@ -259,10 +283,37 @@ def add_user_org(): username = request.args.get("username") org = request.args.get("org_url") + group_name = request.args.get("group_name") with GithubTaskSession(logger) as session: - if username is None: + if username is None or org is None or group_name is None: + return jsonify({"status": "Missing argument"}), 400 + + user = session.query(User).filter( + User.login_name == username).first() + if user is None: + return jsonify({"status": "User does not exist"}) + + repo_load_controller = RepoLoadController(gh_session=session) + + result = repo_load_controller.add_frontend_org(org, user.user_id, group_name) + + return jsonify(result) + + + @server.app.route(f"/{AUGUR_API_VERSION}/user/remove_repo", methods=['GET', 'POST']) + def remove_user_repo(): + if not development and not request.is_secure: + return generate_upgrade_request() + + username = request.args.get("username") + repo_id = request.args.get("repo_id") + group_name = request.args.get("group_name") + + with GithubTaskSession(logger) as session: + + if username is None or repo is None or group_name is None: return jsonify({"status": "Missing argument"}), 400 user = session.query(User).filter( User.login_name == username).first() @@ -271,7 +322,7 @@ def add_user_org(): repo_load_controller = RepoLoadController(gh_session=session) - result = repo_load_controller.add_frontend_org(org, user.user_id) + result = repo_load_controller.remove_frontend_repo(repo_id, user.user_id, group_name) return jsonify(result) diff --git a/augur/application/db/models/augur_data.py b/augur/application/db/models/augur_data.py index 63f105ef0b..5dd5618623 100644 --- a/augur/application/db/models/augur_data.py +++ b/augur/application/db/models/augur_data.py @@ -813,6 +813,7 @@ class Repo(Base): ) repo_group = relationship("RepoGroup") + user_repo = relationship("UserRepo") class RepoTestCoverage(Base): diff --git a/augur/application/db/models/augur_operations.py b/augur/application/db/models/augur_operations.py index 51002a84c9..782bdb11bc 100644 --- a/augur/application/db/models/augur_operations.py +++ b/augur/application/db/models/augur_operations.py @@ -4,6 +4,7 @@ from augur.application.db.models.base import Base +from sqlalchemy.orm import relationship metadata = Base.metadata @@ -191,6 +192,8 @@ class User(Base): {"schema": "augur_operations"} ) + groups = relationship("UserGroup") + class UserGroup(Base): group_id = Column(BigInteger, primary_key=True) @@ -204,6 +207,9 @@ class UserGroup(Base): {"schema": "augur_operations"} ) + user = relationship("User") + repos = relationship("UserRepo") + class UserRepo(Base): @@ -221,3 +227,6 @@ class UserRepo(Base): ForeignKey("augur_data.repo.repo_id", name="user_repo_user_id_fkey"), primary_key=True, nullable=False ) + repo = relationship("Repo") + group = relationship("UserGroup") + diff --git a/augur/application/schema/alembic/versions/2_added_user_group_table.py b/augur/application/schema/alembic/versions/2_added_user_group_table.py index 4334d94a82..6dbc7c8551 100644 --- a/augur/application/schema/alembic/versions/2_added_user_group_table.py +++ b/augur/application/schema/alembic/versions/2_added_user_group_table.py @@ -12,6 +12,7 @@ from augur.application.db.session import DatabaseSession from augur.application.db.models.augur_operations import UserGroup, UserRepo +CLI_USER_ID = 1 # revision identifiers, used by Alembic. @@ -37,9 +38,12 @@ def upgrade(): ); - ALTER TABLE "augur_operations"."user_groups" - OWNER TO "augur"; - """ + ALTER TABLE "augur_operations"."user_groups" + OWNER TO "augur"; + + INSERT INTO "augur_operations"."user_groups" ("group_id", "user_id", "name") VALUES (1, {}, 'default') ON CONFLICT ("user_id", "name") DO NOTHING; + ALTER SEQUENCE user_groups_group_id_seq RESTART WITH 2; + """.format(CLI_USER_ID) session.execute_sql(sa.sql.text(create_user_groups_table)) @@ -52,14 +56,18 @@ def upgrade(): if user_groups: result = [] - for row in user_groups: + for group in user_groups: - user_id = row["user_id"] + user_id = group["user_id"] + + if user_id == CLI_USER_ID: + continue user_group_insert = sa.sql.text(f"""INSERT INTO "augur_operations"."user_groups" ("user_id", "name") VALUES ({user_id}, 'default') RETURNING group_id, user_id;""") result.append(session.fetchall_data_from_sql_text(user_group_insert)[0]) - user_group_id_mapping = {} + # cli user mapping by default + user_group_id_mapping = {CLI_USER_ID: "1"} for row in result: user_group_id_mapping[row["user_id"]] = row["group_id"] diff --git a/augur/util/repo_load_controller.py b/augur/util/repo_load_controller.py index efc619285f..ec47bcca6e 100644 --- a/augur/util/repo_load_controller.py +++ b/augur/util/repo_load_controller.py @@ -10,19 +10,33 @@ from augur.tasks.github.util.github_paginator import GithubPaginator from augur.tasks.github.util.github_task_session import GithubTaskSession from augur.application.db.session import DatabaseSession -from augur.application.db.models import Repo, UserRepo, RepoGroup, UserGroup +from augur.application.db.models import Repo, UserRepo, RepoGroup, UserGroup, User from augur.application.db.util import execute_session_query - logger = logging.getLogger(__name__) - REPO_ENDPOINT = "https://api.github.com/repos/{}/{}" ORG_REPOS_ENDPOINT = "https://api.github.com/orgs/{}/repos?per_page=100" DEFAULT_REPO_GROUP_IDS = [1, 10] CLI_USER_ID = 1 -CLI_GROUP_ID = 1 + + +# determine what that CLI Group ID is +with DatabaseSession(logger) as session: + + user = session.query(User).filter(User.user_id == CLI_USER_ID).one() + user_groups = user.groups + + if len(user_groups) == 0: + cli_user_group = UserGroup(user_id=CLI_USER_ID, name="CLI_Repo_Group") + session.add(cli_user_group) + session.commit() + + else: + cli_user_group = user_groups[0] + + CLI_GROUP_ID = cli_user_group.group_id class RepoLoadController: @@ -176,6 +190,8 @@ def add_repo_to_user_group(self, repo_id, group_id=CLI_GROUP_ID): user_id: id of user_id from users table """ + print(f"Group id: {group_id}") + repo_user_group_data = { "group_id": group_id, "repo_id": repo_id @@ -201,19 +217,46 @@ def add_user_group(self, user_id, group_name): return {"status": "Group created"} else: return {"status": "Group already exists"} + + def remove_user_group(self, user_id, group_name): + + # convert group_name to group_id + group_id = self.convert_group_name_to_id(user_id, group_name) + if group_id is None: + return {"status": "WARNING: Trying to delete group that does not exist"} + + # delete rows from user repos with group_id + self.session.query(UserRepo).filter(UserRepo.group_id == group_id).delete() + + # delete group from user groups table + self.session.query(UserGroup).filter(UserGroup.group_id == group_id).delete() + + self.session.commit() + + return {"status": "Group deleted"} + + + def convert_group_name_to_id(self, user_id, group_name): + + try: + user_group = self.session.query(UserGroup).filter(UserGroup.user_id == user_id, UserGroup.name == group_name).one() + except s.orm.exc.NoResultFound: + return None + + return user_group.group_id def get_user_groups(self, user_id): return self.session.query(UserGroup).filter(UserGroup.user_id == user_id).all() - def get_user_group_repos(self, user_id, group_id): + def get_user_group_repos(self, group_id): - repos = self.session.query(UserRepo).filter(UserGroup.user_id == user_id, UserGroup.group_id == group_id).all() + user_repos = self.session.query(UserRepo).filter(UserRepo.user_id == user_id, UserRepo.group_id == group_id).all() - return [repo["repo_id"] for repo in repos] + return [user_repo.repo.repo_id for user_repo in user_repos] - def add_frontend_repo(self, url: List[str], user_id: int, group_id: int, valid_group=False): + def add_frontend_repo(self, url: List[str], user_id: int, group_name: str, group_id=None, valid_repo=False): """Add list of repos to a users repos. Args: @@ -225,11 +268,14 @@ def add_frontend_repo(self, url: List[str], user_id: int, group_id: int, valid_g If no repo_group_id is passed the repo will be added to a default repo_group """ - if not self.is_valid_repo(url): - return {"status": "Invalid repo", "repo_url": url} + if group_id is None: - if not valid_group and not self.is_valid_user_group(user_id, group_id): - return {"status": "Invalid user group", "group_id": group_id} + group_id = self.convert_group_name_to_id(user_id, group_name) + if group_id is None: + return {"status": "Invalid group name"} + + if not valid_repo and not self.is_valid_repo(url): + return {"status": "Invalid repo", "repo_url": url} repo_id = self.add_repo_row(url, DEFAULT_REPO_GROUP_IDS[0], "Frontend") if not repo_id: @@ -242,23 +288,36 @@ def add_frontend_repo(self, url: List[str], user_id: int, group_id: int, valid_g return {"status": "Repo Added", "repo_url": url} - + def remove_frontend_repo(self, repo_id, user_id, group_name): + + group_id = self.convert_group_name_to_id(user_id, group_name) + if group_id is None: + return {"status": "Invalid group name"} + + # delete rows from user repos with group_id + self.session.query(UserRepo).filter(UserRepo.group_id == group_id, UserRepo.repo_id == repo_id).delete() + self.session.commit() - def add_frontend_org(self, url: List[str], user_id: int, group_id: int): + return {"status": "Repo Removed"} + + + def add_frontend_org(self, url: List[str], user_id: int, group_name: int): """Add list of orgs and their repos to a users repos. Args: urls: list of org urls user_id: id of user_id from users table """ + group_id = self.convert_group_name_to_id(user_id, group_name) + print(group_id) + if group_id is None: + return {"status": "Invalid group name"} repos = self.retrieve_org_repos(url) if not repos: return {"status": "Invalid org", "org_url": url} - if not self.is_valid_user_group(user_id, group_id): - return {"status": "Invalid user group", "group_id": group_id} org_name = self.parse_org_url(url) if not org_name: @@ -269,7 +328,7 @@ def add_frontend_org(self, url: List[str], user_id: int, group_id: int): failed_repos = [] for repo in repos: - result = self.add_frontend_repo(repo, user_id, group_id, valid_group=True) + result = self.add_frontend_repo(repo, user_id, group_name, group_id, valid_repo=True) # keep track of all the repos that failed if result["status"] != "Repo Added": @@ -351,23 +410,21 @@ def get_user_repo_ids(self, user_id: int) -> List[int]: list of repo ids """ - user_repo_id_query = s.sql.text(f"""SELECT * FROM augur_operations.user_repos WHERE user_id={user_id};""") - - - result = self.session.execute_sql(user_repo_id_query).fetchall() + user_groups = session.query(UserGroup).filter(UserGroup.user_id).all() - if len(result) == 0: - return [] + all_repo_ids = set() + for group in user_groups: - repo_ids = [dict(row)["repo_id"] for row in result] + repo_ids = [user_repo.repo.repo_id for user_repo in group.repos] + all_repo_ids.update(repo_ids) - return repo_ids + return list(all_repo_ids) def parse_repo_url(self, url): - if url.endswith(".github") or url.endswith(".github.io"): + if url.endswith(".github") or url.endswith(".github.io") or url.endswith(".js"): result = re.search(r"https?:\/\/github\.com\/([A-Za-z0-9 \- _]+)\/([A-Za-z0-9 \- _ \.]+)(.git)?\/?$", url) else: diff --git a/tests/test_applicaton/test_repo_load_controller/test_repo_load_controller.py b/tests/test_applicaton/test_repo_load_controller/test_repo_load_controller.py index 449d26dba3..bbdece1655 100644 --- a/tests/test_applicaton/test_repo_load_controller/test_repo_load_controller.py +++ b/tests/test_applicaton/test_repo_load_controller/test_repo_load_controller.py @@ -16,7 +16,7 @@ logger = logging.getLogger(__name__) -VALID_ORG = {"org": "CDCgov", "repo_count": 235} +VALID_ORG = {"org": "CDCgov", "repo_count": 241} ######## Helper Functions to Get Delete statements ################# @@ -41,6 +41,10 @@ def get_user_repo_delete_statement(): return get_delete_statement("augur_operations", "user_repos") +def get_user_group_delete_statement(): + + return get_delete_statement("augur_operations", "user_groups") + def get_config_delete_statement(): return get_delete_statement("augur_operations", "config") @@ -58,6 +62,9 @@ def get_repo_related_delete_statements(table_list): if "user_repos" in table_list or "user_repo" in table_list: query_list.append(get_user_repo_delete_statement()) + if "user_groups" in table_list or "user_group" in table_list: + query_list.append(get_user_group_delete_statement()) + if "repos" in table_list or "repo" in table_list: query_list.append(get_repo_delete_statement()) @@ -102,6 +109,13 @@ def get_user_insert_statement(user_id): return """INSERT INTO "augur_operations"."users" ("user_id", "login_name", "login_hashword", "email", "first_name", "last_name", "admin") VALUES ({}, 'bil', 'pass', 'b@gmil.com', 'bill', 'bob', false);""".format(user_id) +def get_user_group_insert_statement(user_id, group_name, group_id=None): + + if group_id: + return """INSERT INTO "augur_operations"."user_groups" ("group_id", "user_id", "name") VALUES ({}, {}, '{}');""".format(group_id, user_id, group_name) + + return """INSERT INTO "augur_operations"."user_groups" (user_id", "name") VALUES (1, 'default');""".format(user_id, group_name) + ######## Helper Functions to get retrieve data from tables ################# @@ -243,32 +257,33 @@ def test_add_repo_row_with_updates(test_db_engine): connection.execute(clear_tables_statement) -def test_add_repo_to_user(test_db_engine): +def test_add_repo_to_user_group(test_db_engine): - clear_tables = ["user_repos", "repo", "repo_groups", "users"] + clear_tables = ["user_repos", "user_groups", "repo", "repo_groups", "users"] clear_tables_statement = get_repo_related_delete_statements(clear_tables) try: with test_db_engine.connect() as connection: - data = {"repo_id": 1, "user_id": 2, "user_repo_group_id": 1} + data = {"repo_id": 1, "user_id": 2, "user_repo_group_id": 1, "user_group_id": 1, "user_group_name": "test_group"} query_statements = [] query_statements.append(clear_tables_statement) query_statements.append(get_repo_group_insert_statement(data["user_repo_group_id"])) query_statements.append(get_repo_insert_statement(data["repo_id"], data["user_repo_group_id"])) query_statements.append(get_user_insert_statement(data["user_id"])) + query_statements.append(get_user_group_insert_statement(data["user_id"], data["user_group_name"], data["user_group_id"])) query = s.text("".join(query_statements)) connection.execute(query) with DatabaseSession(logger, test_db_engine) as session: - RepoLoadController(session).add_repo_to_user(data["repo_id"], data["user_id"]) + RepoLoadController(session).add_repo_to_user_group(data["repo_id"], data["user_group_id"]) with test_db_engine.connect() as connection: - query = s.text("""SELECT * FROM "augur_operations"."user_repos" WHERE "user_id"=:user_id AND "repo_id"=:repo_id""") + query = s.text("""SELECT * FROM "augur_operations"."user_repos" WHERE "group_id"=:user_group_id AND "repo_id"=:repo_id""") result = connection.execute(query, **data).fetchall() assert result is not None @@ -281,7 +296,7 @@ def test_add_repo_to_user(test_db_engine): def test_add_frontend_repos_with_duplicates(test_db_engine): - clear_tables = ["user_repos", "repo", "repo_groups", "users", "config"] + clear_tables = ["user_repos", "user_groups", "repo", "repo_groups", "users", "config"] clear_tables_statement = get_repo_related_delete_statements(clear_tables) try: @@ -289,12 +304,13 @@ def test_add_frontend_repos_with_duplicates(test_db_engine): url = "https://github.com/operate-first/operate-first-twitter" - data = {"user_id": 2, "repo_group_id": DEFAULT_REPO_GROUP_IDS[0]} + data = {"user_id": 2, "repo_group_id": DEFAULT_REPO_GROUP_IDS[0], "user_group_name": "test_group", "user_group_id": 1} query_statements = [] query_statements.append(clear_tables_statement) query_statements.append(get_repo_group_insert_statement(data["repo_group_id"])) query_statements.append(get_user_insert_statement(data["user_id"])) + query_statements.append(get_user_group_insert_statement(data["user_id"], data["user_group_name"], data["user_group_id"])) connection.execute("".join(query_statements)) @@ -303,8 +319,11 @@ def test_add_frontend_repos_with_duplicates(test_db_engine): with GithubTaskSession(logger, test_db_engine) as session: controller = RepoLoadController(session) - controller.add_frontend_repo(url, data["user_id"]) - controller.add_frontend_repo(url, data["user_id"]) + result = controller.add_frontend_repo(url, data["user_id"], data["user_group_name"]) + result2 = controller.add_frontend_repo(url, data["user_id"], data["user_group_name"]) + + assert result["status"] == "Repo Added" + assert result2["status"] == "Repo Added" with test_db_engine.connect() as connection: @@ -320,7 +339,7 @@ def test_add_frontend_repos_with_duplicates(test_db_engine): def test_add_frontend_repos_with_invalid_repo(test_db_engine): - clear_tables = ["user_repos", "repo", "repo_groups", "users", "config"] + clear_tables = ["user_repos", "user_groups", "repo", "repo_groups", "users", "config"] clear_tables_statement = get_repo_related_delete_statements(clear_tables) try: @@ -328,12 +347,13 @@ def test_add_frontend_repos_with_invalid_repo(test_db_engine): url = "https://github.com/chaoss/whitepaper" - data = {"user_id": 2, "repo_group_id": 5} + data = {"user_id": 2, "repo_group_id": 5, "user_group_name": "test_group", "user_group_id": 1} query_statements = [] query_statements.append(clear_tables_statement) query_statements.append(get_repo_group_insert_statement(data["repo_group_id"])) query_statements.append(get_user_insert_statement(data["user_id"])) + query_statements.append(get_user_group_insert_statement(data["user_id"], data["user_group_name"], data["user_group_id"])) connection.execute("".join(query_statements)) @@ -341,7 +361,9 @@ def test_add_frontend_repos_with_invalid_repo(test_db_engine): with GithubTaskSession(logger, test_db_engine) as session: - RepoLoadController(session).add_frontend_repo(url, data["user_id"]) + result = RepoLoadController(session).add_frontend_repo(url, data["user_id"], data["user_group_name"]) + + assert result["status"] == "Invalid repo" with test_db_engine.connect() as connection: @@ -356,12 +378,12 @@ def test_add_frontend_repos_with_invalid_repo(test_db_engine): def test_add_frontend_org_with_invalid_org(test_db_engine): - clear_tables = ["user_repos", "repo", "repo_groups", "users", "config"] + clear_tables = ["user_repos", "user_groups", "repo", "repo_groups", "users", "config"] clear_tables_statement = get_repo_related_delete_statements(clear_tables) try: - data = {"user_id": 2, "repo_group_id": DEFAULT_REPO_GROUP_IDS[0], "org_name": "chaosssss"} + data = {"user_id": 2, "repo_group_id": DEFAULT_REPO_GROUP_IDS[0], "org_name": "chaosssss", "user_group_name": "test_group", "user_group_id": 1} with test_db_engine.connect() as connection: @@ -369,6 +391,7 @@ def test_add_frontend_org_with_invalid_org(test_db_engine): query_statements.append(clear_tables_statement) query_statements.append(get_repo_group_insert_statement(data["repo_group_id"])) query_statements.append(get_user_insert_statement(data["user_id"])) + query_statements.append(get_user_group_insert_statement(data["user_id"], data["user_group_name"], data["user_group_id"])) connection.execute("".join(query_statements)) @@ -376,7 +399,8 @@ def test_add_frontend_org_with_invalid_org(test_db_engine): with GithubTaskSession(logger, test_db_engine) as session: url = f"https://github.com/{data['org_name']}/" - controller = RepoLoadController(session).add_frontend_org(url, data["user_id"]) + result = RepoLoadController(session).add_frontend_org(url, data["user_id"], data["user_group_name"]) + assert result["status"] == "Invalid org" with test_db_engine.connect() as connection: @@ -391,18 +415,20 @@ def test_add_frontend_org_with_invalid_org(test_db_engine): def test_add_frontend_org_with_valid_org(test_db_engine): - clear_tables = ["user_repos", "repo", "repo_groups", "users", "config"] + clear_tables = ["user_repos", "user_groups", "repo", "repo_groups", "users", "config"] clear_tables_statement = get_repo_related_delete_statements(clear_tables) try: with test_db_engine.connect() as connection: - data = {"user_id": 2, "repo_group_id": DEFAULT_REPO_GROUP_IDS[0], "org_name": VALID_ORG["org"]} + data = {"user_id": 2, "repo_group_id": DEFAULT_REPO_GROUP_IDS[0], "org_name": VALID_ORG["org"], "user_group_name": "test_group", "user_group_id": 1} query_statements = [] query_statements.append(clear_tables_statement) query_statements.append(get_repo_group_insert_statement(data["repo_group_id"])) query_statements.append(get_user_insert_statement(data["user_id"])) + query_statements.append(get_user_group_insert_statement(data["user_id"], data["user_group_name"], data["user_group_id"])) + connection.execute("".join(query_statements)) add_keys_to_test_db(test_db_engine) @@ -410,7 +436,9 @@ def test_add_frontend_org_with_valid_org(test_db_engine): with GithubTaskSession(logger, test_db_engine) as session: url = "https://github.com/{}/".format(data["org_name"]) - RepoLoadController(session).add_frontend_org(url, data["user_id"]) + result = RepoLoadController(session).add_frontend_org(url, data["user_id"], data["user_group_name"]) + print(result) + assert result["status"] == "Org repos added" with test_db_engine.connect() as connection: @@ -429,7 +457,7 @@ def test_add_frontend_org_with_valid_org(test_db_engine): def test_add_cli_org_with_valid_org(test_db_engine): - clear_tables = ["user_repos", "repo", "repo_groups", "users", "config"] + clear_tables = ["user_repos", "user_groups", "repo", "repo_groups", "users", "config"] clear_tables_statement = get_repo_related_delete_statements(clear_tables) try: @@ -442,6 +470,7 @@ def test_add_cli_org_with_valid_org(test_db_engine): query_statements.append(get_repo_group_insert_statement(data["repo_group_id"])) query_statements.append(get_user_insert_statement(data["user_id"])) + connection.execute("".join(query_statements)) repo_count = None @@ -450,7 +479,8 @@ def test_add_cli_org_with_valid_org(test_db_engine): with GithubTaskSession(logger, test_db_engine) as session: - RepoLoadController(session).add_cli_org(data["org_name"]) + result = RepoLoadController(session).add_cli_org(data["org_name"]) + print(result) with test_db_engine.connect() as connection: @@ -464,7 +494,8 @@ def test_add_cli_org_with_valid_org(test_db_engine): finally: with test_db_engine.connect() as connection: - connection.execute(clear_tables_statement) + pass + # connection.execute(clear_tables_statement) def test_add_cli_repos_with_duplicates(test_db_engine): @@ -507,3 +538,42 @@ def test_add_cli_repos_with_duplicates(test_db_engine): with test_db_engine.connect() as connection: connection.execute(clear_tables_statement) + + +def test_convert_group_name_to_id(test_db_engine): + + clear_tables = ["user_repos", "repo", "repo_groups", "users"] + clear_tables_statement = get_repo_related_delete_statements(clear_tables) + + try: + with test_db_engine.connect() as connection: + + data = {"user_id": 1, "group_name": "test_group_name", "group_id": 1} + url = f"https://github.com/{data['org_name']}/{data['repo_name']}" + + query_statements = [] + query_statements.append(clear_tables_statement) + query_statements.append(get_user_insert_statement(data["user-id"])) + query_statements.append(get_user_group_insert_statement(data["user_id"], data["group_name"], data["group_id"])) + + connection.execute("".join(query_statements)) + + with GithubTaskSession(logger, test_db_engine) as session: + + repo_data = {"url": url, "repo_group_id": data["repo_group_id"]} + + controller = RepoLoadController(session) + group_id = controller.convert_group_name_to_id(data["user_id"], data["group_name"]) + + assert group_id is not None + assert group_id == data["group_id"] + + finally: + with test_db_engine.connect() as connection: + connection.execute(clear_tables_statement) + + + + + + From 9cfafbf32fa1e25b4132accedc75ebcde4aa2327 Mon Sep 17 00:00:00 2001 From: Andrew Brain Date: Wed, 21 Dec 2022 16:04:16 -0600 Subject: [PATCH 012/150] Pass more repo load controller tests Signed-off-by: Andrew Brain --- augur/util/repo_load_controller.py | 11 +++++------ .../test_repo_load_controller.py | 14 +++++++------- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/augur/util/repo_load_controller.py b/augur/util/repo_load_controller.py index ec47bcca6e..3b055d2acc 100644 --- a/augur/util/repo_load_controller.py +++ b/augur/util/repo_load_controller.py @@ -55,6 +55,8 @@ def is_valid_repo(self, url: str) -> bool: True if repo url is valid and False if not """ + print("Is repo valid?") + owner, repo = self.parse_repo_url(url) if not owner or not repo: return False @@ -190,8 +192,6 @@ def add_repo_to_user_group(self, repo_id, group_id=CLI_GROUP_ID): user_id: id of user_id from users table """ - print(f"Group id: {group_id}") - repo_user_group_data = { "group_id": group_id, "repo_id": repo_id @@ -309,7 +309,6 @@ def add_frontend_org(self, url: List[str], user_id: int, group_name: int): user_id: id of user_id from users table """ group_id = self.convert_group_name_to_id(user_id, group_name) - print(group_id) if group_id is None: return {"status": "Invalid group name"} @@ -341,7 +340,7 @@ def add_frontend_org(self, url: List[str], user_id: int, group_name: int): return {"status": "Org repos added", "org_url": url} - def add_cli_repo(self, repo_data: Dict[str, Any]): + def add_cli_repo(self, repo_data: Dict[str, Any], valid_repo=False): """Add list of repos to specified repo_groups Args: @@ -351,7 +350,7 @@ def add_cli_repo(self, repo_data: Dict[str, Any]): url = repo_data["url"] repo_group_id = repo_data["repo_group_id"] - if self.is_valid_repo(url): + if valid_repo or self.is_valid_repo(url): # if the repo doesn't exist it adds it # if the repo does exist it updates the repo_group_id @@ -397,7 +396,7 @@ def add_cli_org(self, org_name): for repo_url in repos: logger.info( f"Adding {repo_url}") - self.add_cli_repo({"url": repo_url, "repo_group_id": repo_group_id}) + self.add_cli_repo({"url": repo_url, "repo_group_id": repo_group_id}, valid_repo=True) def get_user_repo_ids(self, user_id: int) -> List[int]: diff --git a/tests/test_applicaton/test_repo_load_controller/test_repo_load_controller.py b/tests/test_applicaton/test_repo_load_controller/test_repo_load_controller.py index bbdece1655..5440e100b2 100644 --- a/tests/test_applicaton/test_repo_load_controller/test_repo_load_controller.py +++ b/tests/test_applicaton/test_repo_load_controller/test_repo_load_controller.py @@ -219,7 +219,7 @@ def test_add_repo_row(test_db_engine): def test_add_repo_row_with_updates(test_db_engine): - clear_tables = ["user_repos", "repo", "repo_groups", "users"] + clear_tables = ["user_repos", "user_groups", "repo", "repo_groups", "users"] clear_tables_statement = get_repo_related_delete_statements(clear_tables) try: @@ -437,7 +437,6 @@ def test_add_frontend_org_with_valid_org(test_db_engine): url = "https://github.com/{}/".format(data["org_name"]) result = RepoLoadController(session).add_frontend_org(url, data["user_id"], data["user_group_name"]) - print(result) assert result["status"] == "Org repos added" with test_db_engine.connect() as connection: @@ -463,13 +462,13 @@ def test_add_cli_org_with_valid_org(test_db_engine): try: with test_db_engine.connect() as connection: - data = {"user_id": CLI_USER_ID, "repo_group_id": 5, "org_name": VALID_ORG["org"]} + data = {"user_id": CLI_USER_ID, "repo_group_id": 5, "org_name": VALID_ORG["org"], "user_group_name": "test_group", "user_group_id": 1} query_statements = [] query_statements.append(clear_tables_statement) query_statements.append(get_repo_group_insert_statement(data["repo_group_id"])) query_statements.append(get_user_insert_statement(data["user_id"])) - + query_statements.append(get_user_group_insert_statement(data["user_id"], data["user_group_name"], data["user_group_id"])) connection.execute("".join(query_statements)) @@ -500,19 +499,20 @@ def test_add_cli_org_with_valid_org(test_db_engine): def test_add_cli_repos_with_duplicates(test_db_engine): - clear_tables = ["user_repos", "repo", "repo_groups", "users", "config"] + clear_tables = ["user_repos", "user_groups", "repo", "repo_groups", "users", "config"] clear_tables_statement = get_repo_related_delete_statements(clear_tables) try: with test_db_engine.connect() as connection: - data = {"user_id": CLI_USER_ID, "repo_group_id": 5, "org_name": "operate-first", "repo_name": "operate-first-twitter"} + data = {"user_id": CLI_USER_ID, "repo_group_id": 5, "org_name": "operate-first", "repo_name": "operate-first-twitter", "user_group_name": "test_group", "user_group_id": 1} url = f"https://github.com/{data['org_name']}/{data['repo_name']}" query_statements = [] query_statements.append(clear_tables_statement) query_statements.append(get_repo_group_insert_statement(data["repo_group_id"])) query_statements.append(get_user_insert_statement(data["user_id"])) + query_statements.append(get_user_group_insert_statement(data["user_id"], data["user_group_name"], data["user_group_id"])) connection.execute("".join(query_statements)) @@ -542,7 +542,7 @@ def test_add_cli_repos_with_duplicates(test_db_engine): def test_convert_group_name_to_id(test_db_engine): - clear_tables = ["user_repos", "repo", "repo_groups", "users"] + clear_tables = ["user_repos", "user_groups", "repo", "repo_groups", "users"] clear_tables_statement = get_repo_related_delete_statements(clear_tables) try: From 035ea8d532da5cfa3af813bcbae61f8f65571b0e Mon Sep 17 00:00:00 2001 From: Andrew Brain Date: Thu, 22 Dec 2022 09:42:30 -0600 Subject: [PATCH 013/150] Move around tests for readability Signed-off-by: Andrew Brain --- augur/util/repo_load_controller.py | 28 +- .../test_repo_load_controller/helper.py | 159 +++++ .../test_adding_orgs.py | 134 ++++ .../test_adding_repos.py | 140 +++++ .../test_helper_functions.py | 177 ++++++ .../test_repo_load_controller.py | 579 ------------------ .../test_repo_load_controller/util.py | 146 +++++ 7 files changed, 757 insertions(+), 606 deletions(-) create mode 100644 tests/test_applicaton/test_repo_load_controller/helper.py create mode 100644 tests/test_applicaton/test_repo_load_controller/test_adding_orgs.py create mode 100644 tests/test_applicaton/test_repo_load_controller/test_adding_repos.py create mode 100644 tests/test_applicaton/test_repo_load_controller/test_helper_functions.py delete mode 100644 tests/test_applicaton/test_repo_load_controller/test_repo_load_controller.py create mode 100644 tests/test_applicaton/test_repo_load_controller/util.py diff --git a/augur/util/repo_load_controller.py b/augur/util/repo_load_controller.py index 3b055d2acc..ec644d6232 100644 --- a/augur/util/repo_load_controller.py +++ b/augur/util/repo_load_controller.py @@ -21,24 +21,6 @@ DEFAULT_REPO_GROUP_IDS = [1, 10] CLI_USER_ID = 1 - -# determine what that CLI Group ID is -with DatabaseSession(logger) as session: - - user = session.query(User).filter(User.user_id == CLI_USER_ID).one() - user_groups = user.groups - - if len(user_groups) == 0: - cli_user_group = UserGroup(user_id=CLI_USER_ID, name="CLI_Repo_Group") - session.add(cli_user_group) - session.commit() - - else: - cli_user_group = user_groups[0] - - CLI_GROUP_ID = cli_user_group.group_id - - class RepoLoadController: def __init__(self, gh_session): @@ -86,14 +68,6 @@ def is_valid_repo(self, url: str) -> bool: return True - def is_valid_user_group(self, user_id, group_id) -> bool: - - try: - self.session.query(UserRepo).filter(UserGroup.user_id == user_id, UserGroup.group_id == group_id).one() - return True - except s.orm.exc.NoResultFound: - return False - def retrieve_org_repos(self, url: str) -> List[str]: """Get the repos for an org. @@ -184,7 +158,7 @@ def add_repo_row(self, url: str, repo_group_id: int, tool_source): return result[0]["repo_id"] - def add_repo_to_user_group(self, repo_id, group_id=CLI_GROUP_ID): + def add_repo_to_user_group(self, repo_id, group_id=1): """Add a repo to a user in the user_repos table. Args: diff --git a/tests/test_applicaton/test_repo_load_controller/helper.py b/tests/test_applicaton/test_repo_load_controller/helper.py new file mode 100644 index 0000000000..9223f3b071 --- /dev/null +++ b/tests/test_applicaton/test_repo_load_controller/helper.py @@ -0,0 +1,159 @@ +import sqlalchemy as s +import logging + +from augur.util.repo_load_controller import ORG_REPOS_ENDPOINT + +from augur.application.db.session import DatabaseSession +from augur.application.db.models import Config +from augur.tasks.github.util.github_paginator import hit_api +from augur.application.db.util import execute_session_query + +logger = logging.getLogger(__name__) + + +######## Helper Functions to Get Delete statements ################# + +def get_delete_statement(schema, table): + + return """DELETE FROM "{}"."{}";""".format(schema, table) + +def get_repo_delete_statement(): + + return get_delete_statement("augur_data", "repo") + +def get_repo_group_delete_statement(): + + return get_delete_statement("augur_data", "repo_groups") + +def get_user_delete_statement(): + + return get_delete_statement("augur_operations", "users") + +def get_user_repo_delete_statement(): + + return get_delete_statement("augur_operations", "user_repos") + +def get_user_group_delete_statement(): + + return get_delete_statement("augur_operations", "user_groups") + +def get_config_delete_statement(): + + return get_delete_statement("augur_operations", "config") + +def get_repo_related_delete_statements(table_list): + """Takes a list of tables related to the RepoLoadController class and generates a delete statement. + + Args: + table_list: list of table names. Valid table names are + "user_repos" or "user_repo", "repo" or "repos", "repo_groups" or "repo_group:, "user" or "users", and "config" + + """ + + query_list = [] + if "user_repos" in table_list or "user_repo" in table_list: + query_list.append(get_user_repo_delete_statement()) + + if "user_groups" in table_list or "user_group" in table_list: + query_list.append(get_user_group_delete_statement()) + + if "repos" in table_list or "repo" in table_list: + query_list.append(get_repo_delete_statement()) + + if "repo_groups" in table_list or "repo_group" in table_list: + query_list.append(get_repo_group_delete_statement()) + + if "users" in table_list or "user" in table_list: + query_list.append(get_user_delete_statement()) + + if "config" in table_list: + query_list.append(get_config_delete_statement()) + + return " ".join(query_list) + +######## Helper Functions to add github api keys from prod db to test db ################# +def add_keys_to_test_db(test_db_engine): + + row = None + section_name = "Keys" + setting_name = "github_api_key" + with DatabaseSession(logger) as session: + query = session.query(Config).filter(Config.section_name==section_name, Config.setting_name==setting_name) + row = execute_session_query(query, 'one') + + with DatabaseSession(logger, test_db_engine) as test_session: + new_row = Config(section_name=section_name, setting_name=setting_name, value=row.value, type="str") + test_session.add(new_row) + test_session.commit() + + +######## Helper Functions to get insert statements ################# + +def get_repo_insert_statement(repo_id, rg_id, repo_url="place holder url", repo_status="New"): + + return """INSERT INTO "augur_data"."repo" ("repo_id", "repo_group_id", "repo_git", "repo_path", "repo_name", "repo_added", "repo_status", "repo_type", "url", "owner_id", "description", "primary_language", "created_at", "forked_from", "updated_at", "repo_archived_date_collected", "repo_archived", "tool_source", "tool_version", "data_source", "data_collection_date") VALUES ({}, {}, '{}', NULL, NULL, '2022-08-15 21:08:07', '{}', '', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'CLI', '1.0', 'Git', '2022-08-15 21:08:07');""".format(repo_id, rg_id, repo_url, repo_status) + +def get_repo_group_insert_statement(rg_id): + + return """INSERT INTO "augur_data"."repo_groups" ("repo_group_id", "rg_name", "rg_description", "rg_website", "rg_recache", "rg_last_modified", "rg_type", "tool_source", "tool_version", "data_source", "data_collection_date") VALUES ({}, 'Default Repo Group', 'The default repo group created by the schema generation script', '', 0, '2019-06-03 15:55:20', 'GitHub Organization', 'load', 'one', 'git', '2019-06-05 13:36:25');""".format(rg_id) + +def get_user_insert_statement(user_id): + + return """INSERT INTO "augur_operations"."users" ("user_id", "login_name", "login_hashword", "email", "first_name", "last_name", "admin") VALUES ({}, 'bil', 'pass', 'b@gmil.com', 'bill', 'bob', false);""".format(user_id) + +def get_user_group_insert_statement(user_id, group_name, group_id=None): + + if group_id: + return """INSERT INTO "augur_operations"."user_groups" ("group_id", "user_id", "name") VALUES ({}, {}, '{}');""".format(group_id, user_id, group_name) + + return """INSERT INTO "augur_operations"."user_groups" (user_id", "name") VALUES (1, 'default');""".format(user_id, group_name) + + +######## Helper Functions to get retrieve data from tables ################# + +def get_repos(connection, where_string=None): + + query_list = [] + query_list.append('SELECT * FROM "augur_data"."repo"') + + if where_string: + if where_string.endswith(";"): + query_list.append(where_string[:-1]) + + query_list.append(where_string) + + query_list.append(";") + + query = s.text(" ".join(query_list)) + + return connection.execute(query).fetchall() + +def get_user_repos(connection): + + return connection.execute(s.text("""SELECT * FROM "augur_operations"."user_repos";""")).fetchall() + + +######## Helper Functions to get repos in an org ################# + +def get_org_repos(org_name, session): + + attempts = 0 + while attempts < 10: + result = hit_api(session.oauths, ORG_REPOS_ENDPOINT.format(org_name), logger) + + # if result is None try again + if not result: + attempts += 1 + continue + + response = result.json() + + if response: + return response + + return None + +def get_org_repo_count(org_name, session): + + repos = get_org_repos(org_name, session) + return len(repos) diff --git a/tests/test_applicaton/test_repo_load_controller/test_adding_orgs.py b/tests/test_applicaton/test_repo_load_controller/test_adding_orgs.py new file mode 100644 index 0000000000..985b663016 --- /dev/null +++ b/tests/test_applicaton/test_repo_load_controller/test_adding_orgs.py @@ -0,0 +1,134 @@ +import pytest +import logging + +from tests.test_applicaton.test_repo_load_controller.helper import * +from augur.tasks.github.util.github_task_session import GithubTaskSession + +from augur.util.repo_load_controller import RepoLoadController, DEFAULT_REPO_GROUP_IDS, CLI_USER_ID + + +logger = logging.getLogger(__name__) + + +VALID_ORG = {"org": "CDCgov", "repo_count": 241} + + +def test_add_frontend_org_with_invalid_org(test_db_engine): + + clear_tables = ["user_repos", "user_groups", "repo", "repo_groups", "users", "config"] + clear_tables_statement = get_repo_related_delete_statements(clear_tables) + + try: + + data = {"user_id": 2, "repo_group_id": DEFAULT_REPO_GROUP_IDS[0], "org_name": "chaosssss", "user_group_name": "test_group", "user_group_id": 1} + + with test_db_engine.connect() as connection: + + query_statements = [] + query_statements.append(clear_tables_statement) + query_statements.append(get_repo_group_insert_statement(data["repo_group_id"])) + query_statements.append(get_user_insert_statement(data["user_id"])) + query_statements.append(get_user_group_insert_statement(data["user_id"], data["user_group_name"], data["user_group_id"])) + + connection.execute("".join(query_statements)) + + add_keys_to_test_db(test_db_engine) + with GithubTaskSession(logger, test_db_engine) as session: + + url = f"https://github.com/{data['org_name']}/" + result = RepoLoadController(session).add_frontend_org(url, data["user_id"], data["user_group_name"]) + assert result["status"] == "Invalid org" + + with test_db_engine.connect() as connection: + + result = get_repos(connection) + assert result is not None + assert len(result) == 0 + + finally: + with test_db_engine.connect() as connection: + connection.execute(clear_tables_statement) + + +def test_add_frontend_org_with_valid_org(test_db_engine): + + clear_tables = ["user_repos", "user_groups", "repo", "repo_groups", "users", "config"] + clear_tables_statement = get_repo_related_delete_statements(clear_tables) + + try: + with test_db_engine.connect() as connection: + + data = {"user_id": 2, "repo_group_id": DEFAULT_REPO_GROUP_IDS[0], "org_name": VALID_ORG["org"], "user_group_name": "test_group", "user_group_id": 1} + + query_statements = [] + query_statements.append(clear_tables_statement) + query_statements.append(get_repo_group_insert_statement(data["repo_group_id"])) + query_statements.append(get_user_insert_statement(data["user_id"])) + query_statements.append(get_user_group_insert_statement(data["user_id"], data["user_group_name"], data["user_group_id"])) + + connection.execute("".join(query_statements)) + + add_keys_to_test_db(test_db_engine) + + with GithubTaskSession(logger, test_db_engine) as session: + + url = "https://github.com/{}/".format(data["org_name"]) + result = RepoLoadController(session).add_frontend_org(url, data["user_id"], data["user_group_name"]) + assert result["status"] == "Org repos added" + + with test_db_engine.connect() as connection: + + result = get_repos(connection) + assert result is not None + assert len(result) == VALID_ORG["repo_count"] + + user_repo_result = get_user_repos(connection) + assert user_repo_result is not None + assert len(user_repo_result) == VALID_ORG["repo_count"] + + finally: + with test_db_engine.connect() as connection: + connection.execute(clear_tables_statement) + + +def test_add_cli_org_with_valid_org(test_db_engine): + + clear_tables = ["user_repos", "user_groups", "repo", "repo_groups", "users", "config"] + clear_tables_statement = get_repo_related_delete_statements(clear_tables) + + try: + with test_db_engine.connect() as connection: + + data = {"user_id": CLI_USER_ID, "repo_group_id": 5, "org_name": VALID_ORG["org"], "user_group_name": "test_group", "user_group_id": 1} + + query_statements = [] + query_statements.append(clear_tables_statement) + query_statements.append(get_repo_group_insert_statement(data["repo_group_id"])) + query_statements.append(get_user_insert_statement(data["user_id"])) + query_statements.append(get_user_group_insert_statement(data["user_id"], data["user_group_name"], data["user_group_id"])) + + connection.execute("".join(query_statements)) + + repo_count = None + + add_keys_to_test_db(test_db_engine) + + with GithubTaskSession(logger, test_db_engine) as session: + + result = RepoLoadController(session).add_cli_org(data["org_name"]) + print(result) + + with test_db_engine.connect() as connection: + + result = get_repos(connection) + assert result is not None + assert len(result) == VALID_ORG["repo_count"] + + user_repo_result = get_user_repos(connection) + assert user_repo_result is not None + assert len(user_repo_result) == VALID_ORG["repo_count"] + + finally: + with test_db_engine.connect() as connection: + pass + # connection.execute(clear_tables_statement) diff --git a/tests/test_applicaton/test_repo_load_controller/test_adding_repos.py b/tests/test_applicaton/test_repo_load_controller/test_adding_repos.py new file mode 100644 index 0000000000..ebef2280e4 --- /dev/null +++ b/tests/test_applicaton/test_repo_load_controller/test_adding_repos.py @@ -0,0 +1,140 @@ +import pytest +import logging + +from tests.test_applicaton.test_repo_load_controller.helper import * +from augur.tasks.github.util.github_task_session import GithubTaskSession + +from augur.util.repo_load_controller import RepoLoadController, DEFAULT_REPO_GROUP_IDS, CLI_USER_ID + + + +logger = logging.getLogger(__name__) + + +def test_add_frontend_repos_with_invalid_repo(test_db_engine): + + clear_tables = ["user_repos", "user_groups", "repo", "repo_groups", "users", "config"] + clear_tables_statement = get_repo_related_delete_statements(clear_tables) + + try: + with test_db_engine.connect() as connection: + + url = "https://github.com/chaoss/whitepaper" + + data = {"user_id": 2, "repo_group_id": 5, "user_group_name": "test_group", "user_group_id": 1} + + query_statements = [] + query_statements.append(clear_tables_statement) + query_statements.append(get_repo_group_insert_statement(data["repo_group_id"])) + query_statements.append(get_user_insert_statement(data["user_id"])) + query_statements.append(get_user_group_insert_statement(data["user_id"], data["user_group_name"], data["user_group_id"])) + + connection.execute("".join(query_statements)) + + add_keys_to_test_db(test_db_engine) + + with GithubTaskSession(logger, test_db_engine) as session: + + result = RepoLoadController(session).add_frontend_repo(url, data["user_id"], data["user_group_name"]) + + assert result["status"] == "Invalid repo" + + with test_db_engine.connect() as connection: + + result = get_repos(connection) + assert result is not None + assert len(result) == 0 + + finally: + with test_db_engine.connect() as connection: + connection.execute(clear_tables_statement) + + + + + +def test_add_cli_repos_with_duplicates(test_db_engine): + + clear_tables = ["user_repos", "user_groups", "repo", "repo_groups", "users", "config"] + clear_tables_statement = get_repo_related_delete_statements(clear_tables) + + try: + with test_db_engine.connect() as connection: + + data = {"user_id": CLI_USER_ID, "repo_group_id": 5, "org_name": "operate-first", "repo_name": "operate-first-twitter", "user_group_name": "test_group", "user_group_id": 1} + url = f"https://github.com/{data['org_name']}/{data['repo_name']}" + + query_statements = [] + query_statements.append(clear_tables_statement) + query_statements.append(get_repo_group_insert_statement(data["repo_group_id"])) + query_statements.append(get_user_insert_statement(data["user_id"])) + query_statements.append(get_user_group_insert_statement(data["user_id"], data["user_group_name"], data["user_group_id"])) + + connection.execute("".join(query_statements)) + + add_keys_to_test_db(test_db_engine) + + with GithubTaskSession(logger, test_db_engine) as session: + + repo_data = {"url": url, "repo_group_id": data["repo_group_id"]} + + controller = RepoLoadController(session) + controller.add_cli_repo(repo_data) + controller.add_cli_repo(repo_data) + + with test_db_engine.connect() as connection: + + result = get_repos(connection) + + assert result is not None + assert len(result) == 1 + assert dict(result[0])["repo_git"] == url + + finally: + with test_db_engine.connect() as connection: + connection.execute(clear_tables_statement) + + + + +def test_add_frontend_repos_with_duplicates(test_db_engine): + + clear_tables = ["user_repos", "user_groups", "repo", "repo_groups", "users", "config"] + clear_tables_statement = get_repo_related_delete_statements(clear_tables) + + try: + with test_db_engine.connect() as connection: + + url = "https://github.com/operate-first/operate-first-twitter" + + data = {"user_id": 2, "repo_group_id": DEFAULT_REPO_GROUP_IDS[0], "user_group_name": "test_group", "user_group_id": 1} + + query_statements = [] + query_statements.append(clear_tables_statement) + query_statements.append(get_repo_group_insert_statement(data["repo_group_id"])) + query_statements.append(get_user_insert_statement(data["user_id"])) + query_statements.append(get_user_group_insert_statement(data["user_id"], data["user_group_name"], data["user_group_id"])) + + connection.execute("".join(query_statements)) + + add_keys_to_test_db(test_db_engine) + + with GithubTaskSession(logger, test_db_engine) as session: + + controller = RepoLoadController(session) + result = controller.add_frontend_repo(url, data["user_id"], data["user_group_name"]) + result2 = controller.add_frontend_repo(url, data["user_id"], data["user_group_name"]) + + assert result["status"] == "Repo Added" + assert result2["status"] == "Repo Added" + + with test_db_engine.connect() as connection: + + result = get_repos(connection) + assert result is not None + assert len(result) == 1 + assert dict(result[0])["repo_git"] == url + + finally: + with test_db_engine.connect() as connection: + connection.execute(clear_tables_statement) diff --git a/tests/test_applicaton/test_repo_load_controller/test_helper_functions.py b/tests/test_applicaton/test_repo_load_controller/test_helper_functions.py new file mode 100644 index 0000000000..c7c3807d44 --- /dev/null +++ b/tests/test_applicaton/test_repo_load_controller/test_helper_functions.py @@ -0,0 +1,177 @@ +import logging +import pytest +import sqlalchemy as s + + +from augur.util.repo_load_controller import RepoLoadController, DEFAULT_REPO_GROUP_IDS, CLI_USER_ID + +from augur.application.db.session import DatabaseSession +from augur.tasks.github.util.github_task_session import GithubTaskSession +from tests.test_applicaton.test_repo_load_controller.helper import * + +logger = logging.getLogger(__name__) + + + +def test_is_valid_repo(): + + with GithubTaskSession(logger) as session: + + controller = RepoLoadController(session) + + assert controller.is_valid_repo("hello world") is False + assert controller.is_valid_repo("https://github.com/chaoss/hello") is False + assert controller.is_valid_repo("https://github.com/hello124/augur") is False + assert controller.is_valid_repo("https://github.com//augur") is False + assert controller.is_valid_repo("https://github.com/chaoss/") is False + assert controller.is_valid_repo("https://github.com//") is False + assert controller.is_valid_repo("https://github.com/chaoss/augur") is True + assert controller.is_valid_repo("https://github.com/chaoss/augur/") is True + assert controller.is_valid_repo("https://github.com/chaoss/augur.git") is True + + +def test_add_repo_row(test_db_engine): + + clear_tables = ["repo", "repo_groups"] + clear_tables_statement = get_repo_related_delete_statements(clear_tables) + + try: + data = {"rg_id": 1, "repo_id": 1, "tool_source": "Frontend", + "repo_url": "https://github.com/chaoss/augur"} + + with test_db_engine.connect() as connection: + + query_statements = [] + query_statements.append(clear_tables_statement) + query_statements.append(get_repo_group_insert_statement(data["rg_id"])) + query = s.text("".join(query_statements)) + + connection.execute(query) + + with DatabaseSession(logger, test_db_engine) as session: + + assert RepoLoadController(session).add_repo_row(data["repo_url"], data["rg_id"], data["tool_source"]) is not None + + with test_db_engine.connect() as connection: + + result = get_repos(connection, where_string=f"WHERE repo_git='{data['repo_url']}'") + assert result is not None + assert len(result) > 0 + + finally: + with test_db_engine.connect() as connection: + connection.execute(clear_tables_statement) + + +def test_add_repo_row_with_updates(test_db_engine): + + clear_tables = ["user_repos", "user_groups", "repo", "repo_groups", "users"] + clear_tables_statement = get_repo_related_delete_statements(clear_tables) + + try: + data = {"old_rg_id": 1, "new_rg_id": 2, "repo_id": 1, "repo_id_2": 2, "tool_source": "Test", + "repo_url": "https://github.com/chaoss/augur", "repo_url_2": "https://github.com/chaoss/grimoirelab-perceval-opnfv", "repo_status": "Complete"} + + with test_db_engine.connect() as connection: + + query_statements = [] + query_statements.append(clear_tables_statement) + query_statements.append(get_repo_group_insert_statement(data["old_rg_id"])) + query_statements.append(get_repo_group_insert_statement(data["new_rg_id"])) + query_statements.append(get_repo_insert_statement(data["repo_id"], data["old_rg_id"], repo_url=data["repo_url"], repo_status=data["repo_status"])) + query = s.text("".join(query_statements)) + + connection.execute(query) + + with DatabaseSession(logger, test_db_engine) as session: + + result = RepoLoadController(session).add_repo_row(data["repo_url"], data["new_rg_id"], data["tool_source"]) is not None + assert result == data["repo_id"] + + with test_db_engine.connect() as connection: + + result = get_repos(connection, where_string=f"WHERE repo_git='{data['repo_url']}'") + assert result is not None + assert len(result) == 1 + + value = dict(result[0]) + assert value["repo_status"] == data["repo_status"] + assert value["repo_group_id"] == data["new_rg_id"] + + finally: + with test_db_engine.connect() as connection: + connection.execute(clear_tables_statement) + + +def test_add_repo_to_user_group(test_db_engine): + + clear_tables = ["user_repos", "user_groups", "repo", "repo_groups", "users"] + clear_tables_statement = get_repo_related_delete_statements(clear_tables) + + try: + with test_db_engine.connect() as connection: + + data = {"repo_id": 1, "user_id": 2, "user_repo_group_id": 1, "user_group_id": 1, "user_group_name": "test_group"} + + query_statements = [] + query_statements.append(clear_tables_statement) + query_statements.append(get_repo_group_insert_statement(data["user_repo_group_id"])) + query_statements.append(get_repo_insert_statement(data["repo_id"], data["user_repo_group_id"])) + query_statements.append(get_user_insert_statement(data["user_id"])) + query_statements.append(get_user_group_insert_statement(data["user_id"], data["user_group_name"], data["user_group_id"])) + query = s.text("".join(query_statements)) + + connection.execute(query) + + with DatabaseSession(logger, test_db_engine) as session: + + RepoLoadController(session).add_repo_to_user_group(data["repo_id"], data["user_group_id"]) + + with test_db_engine.connect() as connection: + + query = s.text("""SELECT * FROM "augur_operations"."user_repos" WHERE "group_id"=:user_group_id AND "repo_id"=:repo_id""") + + result = connection.execute(query, **data).fetchall() + assert result is not None + assert len(result) > 0 + + finally: + with test_db_engine.connect() as connection: + connection.execute(clear_tables_statement) + + + +def test_convert_group_name_to_id(test_db_engine): + + clear_tables = ["user_repos", "user_groups", "repo", "repo_groups", "users"] + clear_tables_statement = get_repo_related_delete_statements(clear_tables) + + try: + with test_db_engine.connect() as connection: + + data = {"user_id": 1, "group_name": "test_group_name", "group_id": 1} + + query_statements = [] + query_statements.append(clear_tables_statement) + query_statements.append(get_user_insert_statement(data["user_id"])) + query_statements.append(get_user_group_insert_statement(data["user_id"], data["group_name"], data["group_id"])) + + connection.execute("".join(query_statements)) + + with GithubTaskSession(logger, test_db_engine) as session: + + controller = RepoLoadController(session) + group_id = controller.convert_group_name_to_id(data["user_id"], data["group_name"]) + + assert group_id is not None + assert group_id == data["group_id"] + + finally: + with test_db_engine.connect() as connection: + connection.execute(clear_tables_statement) + + + + + + diff --git a/tests/test_applicaton/test_repo_load_controller/test_repo_load_controller.py b/tests/test_applicaton/test_repo_load_controller/test_repo_load_controller.py deleted file mode 100644 index 5440e100b2..0000000000 --- a/tests/test_applicaton/test_repo_load_controller/test_repo_load_controller.py +++ /dev/null @@ -1,579 +0,0 @@ -import logging -import pytest -import uuid -import sqlalchemy as s - - -from augur.util.repo_load_controller import RepoLoadController, ORG_REPOS_ENDPOINT, DEFAULT_REPO_GROUP_IDS, CLI_USER_ID - -from augur.tasks.github.util.github_task_session import GithubTaskSession -from augur.application.db.session import DatabaseSession -from augur.tasks.github.util.github_paginator import GithubPaginator -from augur.application.db.models import Contributor, Issue, Config -from augur.tasks.github.util.github_paginator import hit_api -from augur.application.db.util import execute_session_query - - -logger = logging.getLogger(__name__) - -VALID_ORG = {"org": "CDCgov", "repo_count": 241} - - -######## Helper Functions to Get Delete statements ################# - -def get_delete_statement(schema, table): - - return """DELETE FROM "{}"."{}";""".format(schema, table) - -def get_repo_delete_statement(): - - return get_delete_statement("augur_data", "repo") - -def get_repo_group_delete_statement(): - - return get_delete_statement("augur_data", "repo_groups") - -def get_user_delete_statement(): - - return get_delete_statement("augur_operations", "users") - -def get_user_repo_delete_statement(): - - return get_delete_statement("augur_operations", "user_repos") - -def get_user_group_delete_statement(): - - return get_delete_statement("augur_operations", "user_groups") - -def get_config_delete_statement(): - - return get_delete_statement("augur_operations", "config") - -def get_repo_related_delete_statements(table_list): - """Takes a list of tables related to the RepoLoadController class and generates a delete statement. - - Args: - table_list: list of table names. Valid table names are - "user_repos" or "user_repo", "repo" or "repos", "repo_groups" or "repo_group:, "user" or "users", and "config" - - """ - - query_list = [] - if "user_repos" in table_list or "user_repo" in table_list: - query_list.append(get_user_repo_delete_statement()) - - if "user_groups" in table_list or "user_group" in table_list: - query_list.append(get_user_group_delete_statement()) - - if "repos" in table_list or "repo" in table_list: - query_list.append(get_repo_delete_statement()) - - if "repo_groups" in table_list or "repo_group" in table_list: - query_list.append(get_repo_group_delete_statement()) - - if "users" in table_list or "user" in table_list: - query_list.append(get_user_delete_statement()) - - if "config" in table_list: - query_list.append(get_config_delete_statement()) - - return " ".join(query_list) - -######## Helper Functions to add github api keys from prod db to test db ################# -def add_keys_to_test_db(test_db_engine): - - row = None - section_name = "Keys" - setting_name = "github_api_key" - with DatabaseSession(logger) as session: - query = session.query(Config).filter(Config.section_name==section_name, Config.setting_name==setting_name) - row = execute_session_query(query, 'one') - - with DatabaseSession(logger, test_db_engine) as test_session: - new_row = Config(section_name=section_name, setting_name=setting_name, value=row.value, type="str") - test_session.add(new_row) - test_session.commit() - - -######## Helper Functions to get insert statements ################# - -def get_repo_insert_statement(repo_id, rg_id, repo_url="place holder url", repo_status="New"): - - return """INSERT INTO "augur_data"."repo" ("repo_id", "repo_group_id", "repo_git", "repo_path", "repo_name", "repo_added", "repo_status", "repo_type", "url", "owner_id", "description", "primary_language", "created_at", "forked_from", "updated_at", "repo_archived_date_collected", "repo_archived", "tool_source", "tool_version", "data_source", "data_collection_date") VALUES ({}, {}, '{}', NULL, NULL, '2022-08-15 21:08:07', '{}', '', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'CLI', '1.0', 'Git', '2022-08-15 21:08:07');""".format(repo_id, rg_id, repo_url, repo_status) - -def get_repo_group_insert_statement(rg_id): - - return """INSERT INTO "augur_data"."repo_groups" ("repo_group_id", "rg_name", "rg_description", "rg_website", "rg_recache", "rg_last_modified", "rg_type", "tool_source", "tool_version", "data_source", "data_collection_date") VALUES ({}, 'Default Repo Group', 'The default repo group created by the schema generation script', '', 0, '2019-06-03 15:55:20', 'GitHub Organization', 'load', 'one', 'git', '2019-06-05 13:36:25');""".format(rg_id) - -def get_user_insert_statement(user_id): - - return """INSERT INTO "augur_operations"."users" ("user_id", "login_name", "login_hashword", "email", "first_name", "last_name", "admin") VALUES ({}, 'bil', 'pass', 'b@gmil.com', 'bill', 'bob', false);""".format(user_id) - -def get_user_group_insert_statement(user_id, group_name, group_id=None): - - if group_id: - return """INSERT INTO "augur_operations"."user_groups" ("group_id", "user_id", "name") VALUES ({}, {}, '{}');""".format(group_id, user_id, group_name) - - return """INSERT INTO "augur_operations"."user_groups" (user_id", "name") VALUES (1, 'default');""".format(user_id, group_name) - - -######## Helper Functions to get retrieve data from tables ################# - -def get_repos(connection, where_string=None): - - query_list = [] - query_list.append('SELECT * FROM "augur_data"."repo"') - - if where_string: - if where_string.endswith(";"): - query_list.append(where_string[:-1]) - - query_list.append(where_string) - - query_list.append(";") - - query = s.text(" ".join(query_list)) - - return connection.execute(query).fetchall() - -def get_user_repos(connection): - - return connection.execute(s.text("""SELECT * FROM "augur_operations"."user_repos";""")).fetchall() - - -######## Helper Functions to get repos in an org ################# - -def get_org_repos(org_name, session): - - attempts = 0 - while attempts < 10: - result = hit_api(session.oauths, ORG_REPOS_ENDPOINT.format(org_name), logger) - - # if result is None try again - if not result: - attempts += 1 - continue - - response = result.json() - - if response: - return response - - return None - -def get_org_repo_count(org_name, session): - - repos = get_org_repos(org_name, session) - return len(repos) - - -def test_is_valid_repo(): - - with GithubTaskSession(logger) as session: - - controller = RepoLoadController(session) - - assert controller.is_valid_repo("hello world") is False - assert controller.is_valid_repo("https://github.com/chaoss/hello") is False - assert controller.is_valid_repo("https://github.com/hello124/augur") is False - assert controller.is_valid_repo("https://github.com//augur") is False - assert controller.is_valid_repo("https://github.com/chaoss/") is False - assert controller.is_valid_repo("https://github.com//") is False - assert controller.is_valid_repo("https://github.com/chaoss/augur") is True - assert controller.is_valid_repo("https://github.com/chaoss/augur/") is True - assert controller.is_valid_repo("https://github.com/chaoss/augur.git") is True - - -def test_add_repo_row(test_db_engine): - - clear_tables = ["repo", "repo_groups"] - clear_tables_statement = get_repo_related_delete_statements(clear_tables) - - try: - data = {"rg_id": 1, "repo_id": 1, "tool_source": "Frontend", - "repo_url": "https://github.com/chaoss/augur"} - - with test_db_engine.connect() as connection: - - query_statements = [] - query_statements.append(clear_tables_statement) - query_statements.append(get_repo_group_insert_statement(data["rg_id"])) - query = s.text("".join(query_statements)) - - connection.execute(query) - - with DatabaseSession(logger, test_db_engine) as session: - - assert RepoLoadController(session).add_repo_row(data["repo_url"], data["rg_id"], data["tool_source"]) is not None - - with test_db_engine.connect() as connection: - - result = get_repos(connection, where_string=f"WHERE repo_git='{data['repo_url']}'") - assert result is not None - assert len(result) > 0 - - finally: - with test_db_engine.connect() as connection: - connection.execute(clear_tables_statement) - - -def test_add_repo_row_with_updates(test_db_engine): - - clear_tables = ["user_repos", "user_groups", "repo", "repo_groups", "users"] - clear_tables_statement = get_repo_related_delete_statements(clear_tables) - - try: - data = {"old_rg_id": 1, "new_rg_id": 2, "repo_id": 1, "repo_id_2": 2, "tool_source": "Test", - "repo_url": "https://github.com/chaoss/augur", "repo_url_2": "https://github.com/chaoss/grimoirelab-perceval-opnfv", "repo_status": "Complete"} - - with test_db_engine.connect() as connection: - - query_statements = [] - query_statements.append(clear_tables_statement) - query_statements.append(get_repo_group_insert_statement(data["old_rg_id"])) - query_statements.append(get_repo_group_insert_statement(data["new_rg_id"])) - query_statements.append(get_repo_insert_statement(data["repo_id"], data["old_rg_id"], repo_url=data["repo_url"], repo_status=data["repo_status"])) - query = s.text("".join(query_statements)) - - connection.execute(query) - - with DatabaseSession(logger, test_db_engine) as session: - - result = RepoLoadController(session).add_repo_row(data["repo_url"], data["new_rg_id"], data["tool_source"]) is not None - assert result == data["repo_id"] - - with test_db_engine.connect() as connection: - - result = get_repos(connection, where_string=f"WHERE repo_git='{data['repo_url']}'") - assert result is not None - assert len(result) == 1 - - value = dict(result[0]) - assert value["repo_status"] == data["repo_status"] - assert value["repo_group_id"] == data["new_rg_id"] - - finally: - with test_db_engine.connect() as connection: - connection.execute(clear_tables_statement) - - -def test_add_repo_to_user_group(test_db_engine): - - clear_tables = ["user_repos", "user_groups", "repo", "repo_groups", "users"] - clear_tables_statement = get_repo_related_delete_statements(clear_tables) - - try: - with test_db_engine.connect() as connection: - - data = {"repo_id": 1, "user_id": 2, "user_repo_group_id": 1, "user_group_id": 1, "user_group_name": "test_group"} - - query_statements = [] - query_statements.append(clear_tables_statement) - query_statements.append(get_repo_group_insert_statement(data["user_repo_group_id"])) - query_statements.append(get_repo_insert_statement(data["repo_id"], data["user_repo_group_id"])) - query_statements.append(get_user_insert_statement(data["user_id"])) - query_statements.append(get_user_group_insert_statement(data["user_id"], data["user_group_name"], data["user_group_id"])) - query = s.text("".join(query_statements)) - - connection.execute(query) - - with DatabaseSession(logger, test_db_engine) as session: - - RepoLoadController(session).add_repo_to_user_group(data["repo_id"], data["user_group_id"]) - - with test_db_engine.connect() as connection: - - query = s.text("""SELECT * FROM "augur_operations"."user_repos" WHERE "group_id"=:user_group_id AND "repo_id"=:repo_id""") - - result = connection.execute(query, **data).fetchall() - assert result is not None - assert len(result) > 0 - - finally: - with test_db_engine.connect() as connection: - connection.execute(clear_tables_statement) - - -def test_add_frontend_repos_with_duplicates(test_db_engine): - - clear_tables = ["user_repos", "user_groups", "repo", "repo_groups", "users", "config"] - clear_tables_statement = get_repo_related_delete_statements(clear_tables) - - try: - with test_db_engine.connect() as connection: - - url = "https://github.com/operate-first/operate-first-twitter" - - data = {"user_id": 2, "repo_group_id": DEFAULT_REPO_GROUP_IDS[0], "user_group_name": "test_group", "user_group_id": 1} - - query_statements = [] - query_statements.append(clear_tables_statement) - query_statements.append(get_repo_group_insert_statement(data["repo_group_id"])) - query_statements.append(get_user_insert_statement(data["user_id"])) - query_statements.append(get_user_group_insert_statement(data["user_id"], data["user_group_name"], data["user_group_id"])) - - connection.execute("".join(query_statements)) - - add_keys_to_test_db(test_db_engine) - - with GithubTaskSession(logger, test_db_engine) as session: - - controller = RepoLoadController(session) - result = controller.add_frontend_repo(url, data["user_id"], data["user_group_name"]) - result2 = controller.add_frontend_repo(url, data["user_id"], data["user_group_name"]) - - assert result["status"] == "Repo Added" - assert result2["status"] == "Repo Added" - - with test_db_engine.connect() as connection: - - result = get_repos(connection) - assert result is not None - assert len(result) == 1 - assert dict(result[0])["repo_git"] == url - - finally: - with test_db_engine.connect() as connection: - connection.execute(clear_tables_statement) - - -def test_add_frontend_repos_with_invalid_repo(test_db_engine): - - clear_tables = ["user_repos", "user_groups", "repo", "repo_groups", "users", "config"] - clear_tables_statement = get_repo_related_delete_statements(clear_tables) - - try: - with test_db_engine.connect() as connection: - - url = "https://github.com/chaoss/whitepaper" - - data = {"user_id": 2, "repo_group_id": 5, "user_group_name": "test_group", "user_group_id": 1} - - query_statements = [] - query_statements.append(clear_tables_statement) - query_statements.append(get_repo_group_insert_statement(data["repo_group_id"])) - query_statements.append(get_user_insert_statement(data["user_id"])) - query_statements.append(get_user_group_insert_statement(data["user_id"], data["user_group_name"], data["user_group_id"])) - - connection.execute("".join(query_statements)) - - add_keys_to_test_db(test_db_engine) - - with GithubTaskSession(logger, test_db_engine) as session: - - result = RepoLoadController(session).add_frontend_repo(url, data["user_id"], data["user_group_name"]) - - assert result["status"] == "Invalid repo" - - with test_db_engine.connect() as connection: - - result = get_repos(connection) - assert result is not None - assert len(result) == 0 - - finally: - with test_db_engine.connect() as connection: - connection.execute(clear_tables_statement) - - -def test_add_frontend_org_with_invalid_org(test_db_engine): - - clear_tables = ["user_repos", "user_groups", "repo", "repo_groups", "users", "config"] - clear_tables_statement = get_repo_related_delete_statements(clear_tables) - - try: - - data = {"user_id": 2, "repo_group_id": DEFAULT_REPO_GROUP_IDS[0], "org_name": "chaosssss", "user_group_name": "test_group", "user_group_id": 1} - - with test_db_engine.connect() as connection: - - query_statements = [] - query_statements.append(clear_tables_statement) - query_statements.append(get_repo_group_insert_statement(data["repo_group_id"])) - query_statements.append(get_user_insert_statement(data["user_id"])) - query_statements.append(get_user_group_insert_statement(data["user_id"], data["user_group_name"], data["user_group_id"])) - - connection.execute("".join(query_statements)) - - add_keys_to_test_db(test_db_engine) - with GithubTaskSession(logger, test_db_engine) as session: - - url = f"https://github.com/{data['org_name']}/" - result = RepoLoadController(session).add_frontend_org(url, data["user_id"], data["user_group_name"]) - assert result["status"] == "Invalid org" - - with test_db_engine.connect() as connection: - - result = get_repos(connection) - assert result is not None - assert len(result) == 0 - - finally: - with test_db_engine.connect() as connection: - connection.execute(clear_tables_statement) - - -def test_add_frontend_org_with_valid_org(test_db_engine): - - clear_tables = ["user_repos", "user_groups", "repo", "repo_groups", "users", "config"] - clear_tables_statement = get_repo_related_delete_statements(clear_tables) - - try: - with test_db_engine.connect() as connection: - - data = {"user_id": 2, "repo_group_id": DEFAULT_REPO_GROUP_IDS[0], "org_name": VALID_ORG["org"], "user_group_name": "test_group", "user_group_id": 1} - - query_statements = [] - query_statements.append(clear_tables_statement) - query_statements.append(get_repo_group_insert_statement(data["repo_group_id"])) - query_statements.append(get_user_insert_statement(data["user_id"])) - query_statements.append(get_user_group_insert_statement(data["user_id"], data["user_group_name"], data["user_group_id"])) - - connection.execute("".join(query_statements)) - - add_keys_to_test_db(test_db_engine) - - with GithubTaskSession(logger, test_db_engine) as session: - - url = "https://github.com/{}/".format(data["org_name"]) - result = RepoLoadController(session).add_frontend_org(url, data["user_id"], data["user_group_name"]) - assert result["status"] == "Org repos added" - - with test_db_engine.connect() as connection: - - result = get_repos(connection) - assert result is not None - assert len(result) == VALID_ORG["repo_count"] - - user_repo_result = get_user_repos(connection) - assert user_repo_result is not None - assert len(user_repo_result) == VALID_ORG["repo_count"] - - finally: - with test_db_engine.connect() as connection: - connection.execute(clear_tables_statement) - - -def test_add_cli_org_with_valid_org(test_db_engine): - - clear_tables = ["user_repos", "user_groups", "repo", "repo_groups", "users", "config"] - clear_tables_statement = get_repo_related_delete_statements(clear_tables) - - try: - with test_db_engine.connect() as connection: - - data = {"user_id": CLI_USER_ID, "repo_group_id": 5, "org_name": VALID_ORG["org"], "user_group_name": "test_group", "user_group_id": 1} - - query_statements = [] - query_statements.append(clear_tables_statement) - query_statements.append(get_repo_group_insert_statement(data["repo_group_id"])) - query_statements.append(get_user_insert_statement(data["user_id"])) - query_statements.append(get_user_group_insert_statement(data["user_id"], data["user_group_name"], data["user_group_id"])) - - connection.execute("".join(query_statements)) - - repo_count = None - - add_keys_to_test_db(test_db_engine) - - with GithubTaskSession(logger, test_db_engine) as session: - - result = RepoLoadController(session).add_cli_org(data["org_name"]) - print(result) - - with test_db_engine.connect() as connection: - - result = get_repos(connection) - assert result is not None - assert len(result) == VALID_ORG["repo_count"] - - user_repo_result = get_user_repos(connection) - assert user_repo_result is not None - assert len(user_repo_result) == VALID_ORG["repo_count"] - - finally: - with test_db_engine.connect() as connection: - pass - # connection.execute(clear_tables_statement) - - -def test_add_cli_repos_with_duplicates(test_db_engine): - - clear_tables = ["user_repos", "user_groups", "repo", "repo_groups", "users", "config"] - clear_tables_statement = get_repo_related_delete_statements(clear_tables) - - try: - with test_db_engine.connect() as connection: - - data = {"user_id": CLI_USER_ID, "repo_group_id": 5, "org_name": "operate-first", "repo_name": "operate-first-twitter", "user_group_name": "test_group", "user_group_id": 1} - url = f"https://github.com/{data['org_name']}/{data['repo_name']}" - - query_statements = [] - query_statements.append(clear_tables_statement) - query_statements.append(get_repo_group_insert_statement(data["repo_group_id"])) - query_statements.append(get_user_insert_statement(data["user_id"])) - query_statements.append(get_user_group_insert_statement(data["user_id"], data["user_group_name"], data["user_group_id"])) - - connection.execute("".join(query_statements)) - - add_keys_to_test_db(test_db_engine) - - with GithubTaskSession(logger, test_db_engine) as session: - - repo_data = {"url": url, "repo_group_id": data["repo_group_id"]} - - controller = RepoLoadController(session) - controller.add_cli_repo(repo_data) - controller.add_cli_repo(repo_data) - - with test_db_engine.connect() as connection: - - result = get_repos(connection) - - assert result is not None - assert len(result) == 1 - assert dict(result[0])["repo_git"] == url - - finally: - with test_db_engine.connect() as connection: - connection.execute(clear_tables_statement) - - - -def test_convert_group_name_to_id(test_db_engine): - - clear_tables = ["user_repos", "user_groups", "repo", "repo_groups", "users"] - clear_tables_statement = get_repo_related_delete_statements(clear_tables) - - try: - with test_db_engine.connect() as connection: - - data = {"user_id": 1, "group_name": "test_group_name", "group_id": 1} - url = f"https://github.com/{data['org_name']}/{data['repo_name']}" - - query_statements = [] - query_statements.append(clear_tables_statement) - query_statements.append(get_user_insert_statement(data["user-id"])) - query_statements.append(get_user_group_insert_statement(data["user_id"], data["group_name"], data["group_id"])) - - connection.execute("".join(query_statements)) - - with GithubTaskSession(logger, test_db_engine) as session: - - repo_data = {"url": url, "repo_group_id": data["repo_group_id"]} - - controller = RepoLoadController(session) - group_id = controller.convert_group_name_to_id(data["user_id"], data["group_name"]) - - assert group_id is not None - assert group_id == data["group_id"] - - finally: - with test_db_engine.connect() as connection: - connection.execute(clear_tables_statement) - - - - - - diff --git a/tests/test_applicaton/test_repo_load_controller/util.py b/tests/test_applicaton/test_repo_load_controller/util.py new file mode 100644 index 0000000000..b77cdb8bfe --- /dev/null +++ b/tests/test_applicaton/test_repo_load_controller/util.py @@ -0,0 +1,146 @@ +######## Helper Functions to Get Delete statements ################# + +def get_delete_statement(schema, table): + + return """DELETE FROM "{}"."{}";""".format(schema, table) + +def get_repo_delete_statement(): + + return get_delete_statement("augur_data", "repo") + +def get_repo_group_delete_statement(): + + return get_delete_statement("augur_data", "repo_groups") + +def get_user_delete_statement(): + + return get_delete_statement("augur_operations", "users") + +def get_user_repo_delete_statement(): + + return get_delete_statement("augur_operations", "user_repos") + +def get_user_group_delete_statement(): + + return get_delete_statement("augur_operations", "user_groups") + +def get_config_delete_statement(): + + return get_delete_statement("augur_operations", "config") + +def get_repo_related_delete_statements(table_list): + """Takes a list of tables related to the RepoLoadController class and generates a delete statement. + + Args: + table_list: list of table names. Valid table names are + "user_repos" or "user_repo", "repo" or "repos", "repo_groups" or "repo_group:, "user" or "users", and "config" + + """ + + query_list = [] + if "user_repos" in table_list or "user_repo" in table_list: + query_list.append(get_user_repo_delete_statement()) + + if "user_groups" in table_list or "user_group" in table_list: + query_list.append(get_user_group_delete_statement()) + + if "repos" in table_list or "repo" in table_list: + query_list.append(get_repo_delete_statement()) + + if "repo_groups" in table_list or "repo_group" in table_list: + query_list.append(get_repo_group_delete_statement()) + + if "users" in table_list or "user" in table_list: + query_list.append(get_user_delete_statement()) + + if "config" in table_list: + query_list.append(get_config_delete_statement()) + + return " ".join(query_list) + +######## Helper Functions to add github api keys from prod db to test db ################# +def add_keys_to_test_db(test_db_engine): + + row = None + section_name = "Keys" + setting_name = "github_api_key" + with DatabaseSession(logger) as session: + query = session.query(Config).filter(Config.section_name==section_name, Config.setting_name==setting_name) + row = execute_session_query(query, 'one') + + with DatabaseSession(logger, test_db_engine) as test_session: + new_row = Config(section_name=section_name, setting_name=setting_name, value=row.value, type="str") + test_session.add(new_row) + test_session.commit() + + +######## Helper Functions to get insert statements ################# + +def get_repo_insert_statement(repo_id, rg_id, repo_url="place holder url", repo_status="New"): + + return """INSERT INTO "augur_data"."repo" ("repo_id", "repo_group_id", "repo_git", "repo_path", "repo_name", "repo_added", "repo_status", "repo_type", "url", "owner_id", "description", "primary_language", "created_at", "forked_from", "updated_at", "repo_archived_date_collected", "repo_archived", "tool_source", "tool_version", "data_source", "data_collection_date") VALUES ({}, {}, '{}', NULL, NULL, '2022-08-15 21:08:07', '{}', '', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'CLI', '1.0', 'Git', '2022-08-15 21:08:07');""".format(repo_id, rg_id, repo_url, repo_status) + +def get_repo_group_insert_statement(rg_id): + + return """INSERT INTO "augur_data"."repo_groups" ("repo_group_id", "rg_name", "rg_description", "rg_website", "rg_recache", "rg_last_modified", "rg_type", "tool_source", "tool_version", "data_source", "data_collection_date") VALUES ({}, 'Default Repo Group', 'The default repo group created by the schema generation script', '', 0, '2019-06-03 15:55:20', 'GitHub Organization', 'load', 'one', 'git', '2019-06-05 13:36:25');""".format(rg_id) + +def get_user_insert_statement(user_id): + + return """INSERT INTO "augur_operations"."users" ("user_id", "login_name", "login_hashword", "email", "first_name", "last_name", "admin") VALUES ({}, 'bil', 'pass', 'b@gmil.com', 'bill', 'bob', false);""".format(user_id) + +def get_user_group_insert_statement(user_id, group_name, group_id=None): + + if group_id: + return """INSERT INTO "augur_operations"."user_groups" ("group_id", "user_id", "name") VALUES ({}, {}, '{}');""".format(group_id, user_id, group_name) + + return """INSERT INTO "augur_operations"."user_groups" (user_id", "name") VALUES (1, 'default');""".format(user_id, group_name) + + +######## Helper Functions to get retrieve data from tables ################# + +def get_repos(connection, where_string=None): + + query_list = [] + query_list.append('SELECT * FROM "augur_data"."repo"') + + if where_string: + if where_string.endswith(";"): + query_list.append(where_string[:-1]) + + query_list.append(where_string) + + query_list.append(";") + + query = s.text(" ".join(query_list)) + + return connection.execute(query).fetchall() + +def get_user_repos(connection): + + return connection.execute(s.text("""SELECT * FROM "augur_operations"."user_repos";""")).fetchall() + + +######## Helper Functions to get repos in an org ################# + +def get_org_repos(org_name, session): + + attempts = 0 + while attempts < 10: + result = hit_api(session.oauths, ORG_REPOS_ENDPOINT.format(org_name), logger) + + # if result is None try again + if not result: + attempts += 1 + continue + + response = result.json() + + if response: + return response + + return None + +def get_org_repo_count(org_name, session): + + repos = get_org_repos(org_name, session) + return len(repos) From ef508edc929cbc1ae01502e5d83dc9f99afa44e4 Mon Sep 17 00:00:00 2001 From: Andrew Brain Date: Thu, 22 Dec 2022 10:32:11 -0600 Subject: [PATCH 014/150] Add more tests Signed-off-by: Andrew Brain --- augur/application/db/session.py | 2 +- augur/util/repo_load_controller.py | 13 +- .../test_helper_functions.py | 138 ++++++++++++++---- 3 files changed, 123 insertions(+), 30 deletions(-) diff --git a/augur/application/db/session.py b/augur/application/db/session.py index 65b913a713..d4745fab5b 100644 --- a/augur/application/db/session.py +++ b/augur/application/db/session.py @@ -202,7 +202,7 @@ def insert_data(self, data: Union[List[dict], dict], table, natural_keys: List[s if deadlock_detected is True: self.logger.error("Made it through even though Deadlock was detected") - return None + return "success" # othewise it gets the requested return columns and returns them as a list of dicts diff --git a/augur/util/repo_load_controller.py b/augur/util/repo_load_controller.py index ec644d6232..f211242b2d 100644 --- a/augur/util/repo_load_controller.py +++ b/augur/util/repo_load_controller.py @@ -37,8 +37,6 @@ def is_valid_repo(self, url: str) -> bool: True if repo url is valid and False if not """ - print("Is repo valid?") - owner, repo = self.parse_repo_url(url) if not owner or not repo: return False @@ -109,7 +107,11 @@ def retrieve_org_repos(self, url: str) -> List[str]: def is_valid_repo_group_id(self, repo_group_id): query = self.session.query(RepoGroup).filter(RepoGroup.repo_group_id == repo_group_id) - result = execute_session_query(query, 'one') + + try: + result = execute_session_query(query, 'one') + except (s.orm.exc.NoResultFound, s.orm.exc.MultipleResultsFound): + return False if result and result.repo_group_id == repo_group_id: return True @@ -127,6 +129,9 @@ def add_repo_row(self, url: str, repo_group_id: int, tool_source): If repo row exists then it will update the repo_group_id if param repo_group_id is not a default. If it does not exist is will simply insert the repo. """ + if not isinstance(url, str) or not isinstance(repo_group_id, int) or not isinstance(tool_source, str): + return None + if not self.is_valid_repo_group_id(repo_group_id): return None @@ -153,7 +158,7 @@ def add_repo_row(self, url: str, repo_group_id: int, tool_source): if not repo.repo_group_id == repo_group_id: repo.repo_group_id = repo_group_id - self.session.commit() + self.session.commit() return result[0]["repo_id"] diff --git a/tests/test_applicaton/test_repo_load_controller/test_helper_functions.py b/tests/test_applicaton/test_repo_load_controller/test_helper_functions.py index c7c3807d44..2a303ba8c1 100644 --- a/tests/test_applicaton/test_repo_load_controller/test_helper_functions.py +++ b/tests/test_applicaton/test_repo_load_controller/test_helper_functions.py @@ -12,6 +12,37 @@ logger = logging.getLogger(__name__) +def test_parse_repo_url(): + + with DatabaseSession(logger) as session: + + controller = RepoLoadController(session) + + assert controller.parse_repo_url("hello world") == (None, None) + assert controller.parse_repo_url("https://github.com/chaoss/hello") == ("chaoss", "hello") + assert controller.parse_repo_url("https://github.com/hello124/augur") == ("hello124", "augur") + assert controller.parse_repo_url("https://github.com//augur") == (None, None) + assert controller.parse_repo_url("https://github.com/chaoss/") == (None, None) + assert controller.parse_repo_url("https://github.com//") == (None, None) + assert controller.parse_repo_url("https://github.com/chaoss/augur") == ("chaoss", "augur") + assert controller.parse_repo_url("https://github.com/chaoss/augur/") == ("chaoss", "augur") + assert controller.parse_repo_url("https://github.com/chaoss/augur.git") == ("chaoss", "augur") + + +def test_parse_org_url(): + + with DatabaseSession(logger) as session: + + controller = RepoLoadController(session) + + assert controller.parse_org_url("hello world") == None, None + assert controller.parse_org_url("https://github.com/chaoss/") == "chaoss" + assert controller.parse_org_url("https://github.com/chaoss") == "chaoss" + assert controller.parse_org_url("https://github.com/hello124/augur") == None + assert controller.parse_org_url("https://github.com//augur") == None, None + assert controller.parse_org_url("https://github.com//") == None + assert controller.parse_org_url("https://github.com/chaoss/augur") == None + def test_is_valid_repo(): @@ -29,80 +60,137 @@ def test_is_valid_repo(): assert controller.is_valid_repo("https://github.com/chaoss/augur/") is True assert controller.is_valid_repo("https://github.com/chaoss/augur.git") is True +def test_is_valid_repo_group_id(test_db_engine): -def test_add_repo_row(test_db_engine): - - clear_tables = ["repo", "repo_groups"] + clear_tables = ["repo_groups"] clear_tables_statement = get_repo_related_delete_statements(clear_tables) try: - data = {"rg_id": 1, "repo_id": 1, "tool_source": "Frontend", + + + data = {"rg_ids": [1, 2, 3], "repo_id": 1, "tool_source": "Frontend", "repo_url": "https://github.com/chaoss/augur"} with test_db_engine.connect() as connection: query_statements = [] query_statements.append(clear_tables_statement) - query_statements.append(get_repo_group_insert_statement(data["rg_id"])) + query_statements.append(get_repo_group_insert_statement(data["rg_ids"][0])) + query_statements.append(get_repo_group_insert_statement(data["rg_ids"][1])) + query_statements.append(get_repo_group_insert_statement(data["rg_ids"][2])) query = s.text("".join(query_statements)) connection.execute(query) with DatabaseSession(logger, test_db_engine) as session: - assert RepoLoadController(session).add_repo_row(data["repo_url"], data["rg_id"], data["tool_source"]) is not None + controller = RepoLoadController(session) - with test_db_engine.connect() as connection: + # valid + assert controller.is_valid_repo_group_id(data["rg_ids"][0]) is True + assert controller.is_valid_repo_group_id(data["rg_ids"][1]) is True + assert controller.is_valid_repo_group_id(data["rg_ids"][2]) is True + + + # invalid + assert controller.is_valid_repo_group_id(-1) is False + assert controller.is_valid_repo_group_id(12) is False + assert controller.is_valid_repo_group_id(11111) is False - result = get_repos(connection, where_string=f"WHERE repo_git='{data['repo_url']}'") - assert result is not None - assert len(result) > 0 finally: with test_db_engine.connect() as connection: connection.execute(clear_tables_statement) -def test_add_repo_row_with_updates(test_db_engine): +def test_add_repo_row(test_db_engine): - clear_tables = ["user_repos", "user_groups", "repo", "repo_groups", "users"] + clear_tables = ["repo", "repo_groups"] clear_tables_statement = get_repo_related_delete_statements(clear_tables) try: - data = {"old_rg_id": 1, "new_rg_id": 2, "repo_id": 1, "repo_id_2": 2, "tool_source": "Test", - "repo_url": "https://github.com/chaoss/augur", "repo_url_2": "https://github.com/chaoss/grimoirelab-perceval-opnfv", "repo_status": "Complete"} + data = {"rg_id": 1, + "tool_source": "Frontend", + "repo_urls": ["https://github.com/chaoss/augur", "https://github.com/chaoss/grimoirelab-sortinghat"] + } with test_db_engine.connect() as connection: query_statements = [] query_statements.append(clear_tables_statement) - query_statements.append(get_repo_group_insert_statement(data["old_rg_id"])) - query_statements.append(get_repo_group_insert_statement(data["new_rg_id"])) - query_statements.append(get_repo_insert_statement(data["repo_id"], data["old_rg_id"], repo_url=data["repo_url"], repo_status=data["repo_status"])) + query_statements.append(get_repo_group_insert_statement(data["rg_id"])) query = s.text("".join(query_statements)) connection.execute(query) with DatabaseSession(logger, test_db_engine) as session: - result = RepoLoadController(session).add_repo_row(data["repo_url"], data["new_rg_id"], data["tool_source"]) is not None - assert result == data["repo_id"] + assert RepoLoadController(session).add_repo_row(data["repo_urls"][0], data["rg_id"], data["tool_source"]) is not None + assert RepoLoadController(session).add_repo_row(data["repo_urls"][1], data["rg_id"], data["tool_source"]) is not None + + # invalid rg_id + assert RepoLoadController(session).add_repo_row(data["repo_urls"][0], 12, data["tool_source"]) is None + + # invalid type for repo url + assert RepoLoadController(session).add_repo_row(1, data["rg_id"], data["tool_source"]) is None + + # invalid type for rg_id + assert RepoLoadController(session).add_repo_row(data["repo_urls"][1], "1", data["tool_source"]) is None + + # invalid type for tool_source + assert RepoLoadController(session).add_repo_row(data["repo_urls"][1], data["rg_id"], 52) is None with test_db_engine.connect() as connection: - result = get_repos(connection, where_string=f"WHERE repo_git='{data['repo_url']}'") + result = get_repos(connection) assert result is not None - assert len(result) == 1 - - value = dict(result[0]) - assert value["repo_status"] == data["repo_status"] - assert value["repo_group_id"] == data["new_rg_id"] + assert len(result) == len(data["repo_urls"]) finally: with test_db_engine.connect() as connection: connection.execute(clear_tables_statement) +# def test_add_repo_row_with_updates(test_db_engine): + +# clear_tables = ["user_repos", "user_groups", "repo", "repo_groups", "users"] +# clear_tables_statement = get_repo_related_delete_statements(clear_tables) + +# try: +# data = {"old_rg_id": 1, "new_rg_id": 2, "repo_id": 1, "repo_id_2": 2, "tool_source": "Test", +# "repo_url": "https://github.com/chaoss/augur", "repo_url_2": "https://github.com/chaoss/grimoirelab-perceval-opnfv", "repo_status": "Complete"} + +# with test_db_engine.connect() as connection: + +# query_statements = [] +# query_statements.append(clear_tables_statement) +# query_statements.append(get_repo_group_insert_statement(data["old_rg_id"])) +# query_statements.append(get_repo_group_insert_statement(data["new_rg_id"])) +# query_statements.append(get_repo_insert_statement(data["repo_id"], data["old_rg_id"], repo_url=data["repo_url"], repo_status=data["repo_status"])) +# query = s.text("".join(query_statements)) + +# connection.execute(query) + +# with DatabaseSession(logger, test_db_engine) as session: + +# result = RepoLoadController(session).add_repo_row(data["repo_url"], data["new_rg_id"], data["tool_source"]) is not None +# assert result == data["repo_id"] + +# with test_db_engine.connect() as connection: + +# result = get_repos(connection, where_string=f"WHERE repo_git='{data['repo_url']}'") +# assert result is not None +# assert len(result) == 1 + +# value = dict(result[0]) +# assert value["repo_status"] == data["repo_status"] +# assert value["repo_group_id"] == data["new_rg_id"] + +# finally: +# with test_db_engine.connect() as connection: +# connection.execute(clear_tables_statement) + + def test_add_repo_to_user_group(test_db_engine): clear_tables = ["user_repos", "user_groups", "repo", "repo_groups", "users"] From b42648fd01e5a06f210e6b356ba77666445e2d48 Mon Sep 17 00:00:00 2001 From: Andrew Brain Date: Thu, 22 Dec 2022 10:58:12 -0600 Subject: [PATCH 015/150] Add more tests Signed-off-by: Andrew Brain --- augur/util/repo_load_controller.py | 9 +- .../test_helper_functions.py | 125 +++++++++++++----- 2 files changed, 99 insertions(+), 35 deletions(-) diff --git a/augur/util/repo_load_controller.py b/augur/util/repo_load_controller.py index f211242b2d..e5ee26a08a 100644 --- a/augur/util/repo_load_controller.py +++ b/augur/util/repo_load_controller.py @@ -171,6 +171,9 @@ def add_repo_to_user_group(self, repo_id, group_id=1): user_id: id of user_id from users table """ + if not isinstance(repo_id, int) or not isinstance(group_id, int): + return False + repo_user_group_data = { "group_id": group_id, "repo_id": repo_id @@ -179,7 +182,11 @@ def add_repo_to_user_group(self, repo_id, group_id=1): repo_user_group_unique = ["group_id", "repo_id"] return_columns = ["group_id", "repo_id"] - data = self.session.insert_data(repo_user_group_data, UserRepo, repo_user_group_unique, return_columns) + + try: + data = self.session.insert_data(repo_user_group_data, UserRepo, repo_user_group_unique, return_columns) + except s.exc.IntegrityError: + return False return data[0]["group_id"] == group_id and data[0]["repo_id"] == repo_id diff --git a/tests/test_applicaton/test_repo_load_controller/test_helper_functions.py b/tests/test_applicaton/test_repo_load_controller/test_helper_functions.py index 2a303ba8c1..8d3d779178 100644 --- a/tests/test_applicaton/test_repo_load_controller/test_helper_functions.py +++ b/tests/test_applicaton/test_repo_load_controller/test_helper_functions.py @@ -151,44 +151,44 @@ def test_add_repo_row(test_db_engine): connection.execute(clear_tables_statement) -# def test_add_repo_row_with_updates(test_db_engine): +def test_add_repo_row_with_updates(test_db_engine): -# clear_tables = ["user_repos", "user_groups", "repo", "repo_groups", "users"] -# clear_tables_statement = get_repo_related_delete_statements(clear_tables) + clear_tables = ["user_repos", "user_groups", "repo", "repo_groups", "users"] + clear_tables_statement = get_repo_related_delete_statements(clear_tables) -# try: -# data = {"old_rg_id": 1, "new_rg_id": 2, "repo_id": 1, "repo_id_2": 2, "tool_source": "Test", -# "repo_url": "https://github.com/chaoss/augur", "repo_url_2": "https://github.com/chaoss/grimoirelab-perceval-opnfv", "repo_status": "Complete"} + try: + data = {"old_rg_id": 1, "new_rg_id": 2, "repo_id": 1, "repo_id_2": 2, "tool_source": "Test", + "repo_url": "https://github.com/chaoss/augur", "repo_url_2": "https://github.com/chaoss/grimoirelab-perceval-opnfv", "repo_status": "Complete"} -# with test_db_engine.connect() as connection: + with test_db_engine.connect() as connection: -# query_statements = [] -# query_statements.append(clear_tables_statement) -# query_statements.append(get_repo_group_insert_statement(data["old_rg_id"])) -# query_statements.append(get_repo_group_insert_statement(data["new_rg_id"])) -# query_statements.append(get_repo_insert_statement(data["repo_id"], data["old_rg_id"], repo_url=data["repo_url"], repo_status=data["repo_status"])) -# query = s.text("".join(query_statements)) + query_statements = [] + query_statements.append(clear_tables_statement) + query_statements.append(get_repo_group_insert_statement(data["old_rg_id"])) + query_statements.append(get_repo_group_insert_statement(data["new_rg_id"])) + query_statements.append(get_repo_insert_statement(data["repo_id"], data["old_rg_id"], repo_url=data["repo_url"], repo_status=data["repo_status"])) + query = s.text("".join(query_statements)) -# connection.execute(query) + connection.execute(query) -# with DatabaseSession(logger, test_db_engine) as session: + with DatabaseSession(logger, test_db_engine) as session: -# result = RepoLoadController(session).add_repo_row(data["repo_url"], data["new_rg_id"], data["tool_source"]) is not None -# assert result == data["repo_id"] + result = RepoLoadController(session).add_repo_row(data["repo_url"], data["new_rg_id"], data["tool_source"]) is not None + assert result == data["repo_id"] -# with test_db_engine.connect() as connection: + with test_db_engine.connect() as connection: -# result = get_repos(connection, where_string=f"WHERE repo_git='{data['repo_url']}'") -# assert result is not None -# assert len(result) == 1 + result = get_repos(connection, where_string=f"WHERE repo_git='{data['repo_url']}'") + assert result is not None + assert len(result) == 1 -# value = dict(result[0]) -# assert value["repo_status"] == data["repo_status"] -# assert value["repo_group_id"] == data["new_rg_id"] + value = dict(result[0]) + assert value["repo_status"] == data["repo_status"] + assert value["repo_group_id"] == data["new_rg_id"] -# finally: -# with test_db_engine.connect() as connection: -# connection.execute(clear_tables_statement) + finally: + with test_db_engine.connect() as connection: + connection.execute(clear_tables_statement) def test_add_repo_to_user_group(test_db_engine): @@ -199,35 +199,92 @@ def test_add_repo_to_user_group(test_db_engine): try: with test_db_engine.connect() as connection: - data = {"repo_id": 1, "user_id": 2, "user_repo_group_id": 1, "user_group_id": 1, "user_group_name": "test_group"} + data = {"repo_ids": [1, 2, 3], "repo_urls":["url 1", "url2", "url3"], "user_id": 2, "user_repo_group_id": 1, "user_group_ids": [1, 2], "user_group_names": ["test_group", "test_group_2"]} query_statements = [] query_statements.append(clear_tables_statement) query_statements.append(get_repo_group_insert_statement(data["user_repo_group_id"])) - query_statements.append(get_repo_insert_statement(data["repo_id"], data["user_repo_group_id"])) + + for i in range(0, len(data["repo_ids"])): + query_statements.append(get_repo_insert_statement(data["repo_ids"][i], data["user_repo_group_id"], data["repo_urls"][i])) + query_statements.append(get_user_insert_statement(data["user_id"])) - query_statements.append(get_user_group_insert_statement(data["user_id"], data["user_group_name"], data["user_group_id"])) + + for i in range(0, len(data["user_group_ids"])): + query_statements.append(get_user_group_insert_statement(data["user_id"], data["user_group_names"][i], data["user_group_ids"][i])) + query = s.text("".join(query_statements)) connection.execute(query) with DatabaseSession(logger, test_db_engine) as session: - RepoLoadController(session).add_repo_to_user_group(data["repo_id"], data["user_group_id"]) + controller = RepoLoadController(session) + + # add valid repo to group 0 + assert controller.add_repo_to_user_group(data["repo_ids"][0], data["user_group_ids"][0]) is True + + # add repo again to group 0 ... should be 1 repo row still + assert controller.add_repo_to_user_group(data["repo_ids"][0], data["user_group_ids"][0]) is True + + # add another valid repo to group 0 + assert controller.add_repo_to_user_group(data["repo_ids"][1], data["user_group_ids"][0]) is True + + # add same repo to group 1 + assert controller.add_repo_to_user_group(data["repo_ids"][0], data["user_group_ids"][1]) is True + + # add different repo to group 1 + assert controller.add_repo_to_user_group(data["repo_ids"][2], data["user_group_ids"][1]) is True + + # add with invalid repo id + assert controller.add_repo_to_user_group(130000, data["user_group_ids"][1]) is False + + # add with invalid group_id + assert controller.add_repo_to_user_group(data["repo_ids"][0], 133333) is False + + # pass invalid tpyes + assert controller.add_repo_to_user_group("130000", data["user_group_ids"][1]) is False + assert controller.add_repo_to_user_group(data["repo_ids"][0], "133333") is False + + + # end result + # 4 rows in table + # 2 rows in each group + with test_db_engine.connect() as connection: - query = s.text("""SELECT * FROM "augur_operations"."user_repos" WHERE "group_id"=:user_group_id AND "repo_id"=:repo_id""") + query = s.text("""SELECT * FROM "augur_operations"."user_repos";""") + # WHERE "group_id"=:user_group_id AND "repo_id"=:repo_id + + result = connection.execute(query).fetchall() + assert result is not None + assert len(result) == 4 + + + query = s.text("""SELECT * FROM "augur_operations"."user_repos" WHERE "group_id"={};""".format(data["user_group_ids"][0])) + + result = connection.execute(query).fetchall() + assert result is not None + assert len(result) == 2 - result = connection.execute(query, **data).fetchall() + + query = s.text("""SELECT * FROM "augur_operations"."user_repos" WHERE "group_id"={};""".format(data["user_group_ids"][0])) + + result = connection.execute(query).fetchall() assert result is not None - assert len(result) > 0 + assert len(result) == 2 finally: with test_db_engine.connect() as connection: connection.execute(clear_tables_statement) +def test_add_user_group(): + + pass + + def test_convert_group_name_to_id(test_db_engine): From 3920591a2e2fd77d8adb59b1253bc2f91b04d1ba Mon Sep 17 00:00:00 2001 From: Andrew Brain Date: Thu, 22 Dec 2022 11:44:46 -0600 Subject: [PATCH 016/150] Add more tests Signed-off-by: Andrew Brain --- augur/util/repo_load_controller.py | 14 +- .../test_repo_load_controller/helper.py | 4 +- .../test_helper_functions.py | 127 ++++++++++++++++-- 3 files changed, 133 insertions(+), 12 deletions(-) diff --git a/augur/util/repo_load_controller.py b/augur/util/repo_load_controller.py index e5ee26a08a..ccecb93d2b 100644 --- a/augur/util/repo_load_controller.py +++ b/augur/util/repo_load_controller.py @@ -192,17 +192,24 @@ def add_repo_to_user_group(self, repo_id, group_id=1): def add_user_group(self, user_id, group_name): + if not isinstance(user_id, int) or not isinstance(group_name, str): + return {"status": "Invalid input"} + user_group_data = { "name": group_name, "user_id": user_id } - result = self.session.insert_data(user_group_data, UserGroup, ["name", "user_id"], return_columns=["group_id"]) + try: + result = self.session.insert_data(user_group_data, UserGroup, ["name", "user_id"], return_columns=["group_id"]) + except s.exc.IntegrityError: + return {"status": "Error: User id does not exist"} + if result: return {"status": "Group created"} else: - return {"status": "Group already exists"} + return {"status": "Error while creating group"} def remove_user_group(self, user_id, group_name): @@ -224,6 +231,9 @@ def remove_user_group(self, user_id, group_name): def convert_group_name_to_id(self, user_id, group_name): + if not isinstance(user_id, int) or not isinstance(group_name, str): + return None + try: user_group = self.session.query(UserGroup).filter(UserGroup.user_id == user_id, UserGroup.name == group_name).one() except s.orm.exc.NoResultFound: diff --git a/tests/test_applicaton/test_repo_load_controller/helper.py b/tests/test_applicaton/test_repo_load_controller/helper.py index 9223f3b071..cea59fccf1 100644 --- a/tests/test_applicaton/test_repo_load_controller/helper.py +++ b/tests/test_applicaton/test_repo_load_controller/helper.py @@ -97,9 +97,9 @@ def get_repo_group_insert_statement(rg_id): return """INSERT INTO "augur_data"."repo_groups" ("repo_group_id", "rg_name", "rg_description", "rg_website", "rg_recache", "rg_last_modified", "rg_type", "tool_source", "tool_version", "data_source", "data_collection_date") VALUES ({}, 'Default Repo Group', 'The default repo group created by the schema generation script', '', 0, '2019-06-03 15:55:20', 'GitHub Organization', 'load', 'one', 'git', '2019-06-05 13:36:25');""".format(rg_id) -def get_user_insert_statement(user_id): +def get_user_insert_statement(user_id, username="bil", email="default@gmail.com"): - return """INSERT INTO "augur_operations"."users" ("user_id", "login_name", "login_hashword", "email", "first_name", "last_name", "admin") VALUES ({}, 'bil', 'pass', 'b@gmil.com', 'bill', 'bob', false);""".format(user_id) + return """INSERT INTO "augur_operations"."users" ("user_id", "login_name", "login_hashword", "email", "first_name", "last_name", "admin") VALUES ({}, '{}', 'pass', '{}', 'bill', 'bob', false);""".format(user_id, username, email) def get_user_group_insert_statement(user_id, group_name, group_id=None): diff --git a/tests/test_applicaton/test_repo_load_controller/test_helper_functions.py b/tests/test_applicaton/test_repo_load_controller/test_helper_functions.py index 8d3d779178..90e983ae6e 100644 --- a/tests/test_applicaton/test_repo_load_controller/test_helper_functions.py +++ b/tests/test_applicaton/test_repo_load_controller/test_helper_functions.py @@ -280,9 +280,94 @@ def test_add_repo_to_user_group(test_db_engine): connection.execute(clear_tables_statement) -def test_add_user_group(): +def test_add_user_group(test_db_engine): - pass + clear_tables = ["user_repos", "user_groups", "repo", "repo_groups", "users"] + clear_tables_statement = get_repo_related_delete_statements(clear_tables) + + try: + with test_db_engine.connect() as connection: + + data = { + "users": [ + { + "id": 0, + "username": "user 1", + "email": "email 1" + }, + { + "id": 1, + "username": "user 2", + "email": "email 2" + } + ], + "group_names": ["test_group", "test_group_2"]} + + query_statements = [] + query_statements.append(clear_tables_statement) + + for user in data["users"]: + query_statements.append(get_user_insert_statement(user["id"], user["username"], user["email"])) + + query = s.text("".join(query_statements)) + + connection.execute(query) + + with DatabaseSession(logger, test_db_engine) as session: + + controller = RepoLoadController(session) + + # add valid group to user 0 + assert controller.add_user_group(data["users"][0]["id"], data["group_names"][0])["status"] == "Group created" + + # add group again to user 0 ... should be 1 group row still + assert controller.add_user_group(data["users"][0]["id"], data["group_names"][0])["status"] == "Group created" + + # add another valid group to user 0 + assert controller.add_user_group(data["users"][0]["id"], data["group_names"][1])["status"] == "Group created" + + # add same group to user 1 + assert controller.add_user_group(data["users"][1]["id"], data["group_names"][0])["status"] == "Group created" + + + # add with invalid user id + assert controller.add_user_group(130000, data["group_names"][0])["status"] == "Error: User id does not exist" + + # pass invalid tpyes + assert controller.add_user_group("130000", data["group_names"][0])["status"] == "Invalid input" + assert controller.add_user_group(data["users"][0]["id"], 133333)["status"] == "Invalid input" + + + # end result + # 3 groups in table + # 1 row for user 1 + # 2 rows for user 0 + + + with test_db_engine.connect() as connection: + + query = s.text("""SELECT * FROM "augur_operations"."user_groups";""") + + result = connection.execute(query).fetchall() + assert result is not None + assert len(result) == 3 + + query = s.text("""SELECT * FROM "augur_operations"."user_groups" WHERE "user_id"={};""".format(data["users"][0]["id"])) + + result = connection.execute(query).fetchall() + assert result is not None + assert len(result) == 2 + + query = s.text("""SELECT * FROM "augur_operations"."user_groups" WHERE "user_id"={};""".format(data["users"][1]["id"])) + + result = connection.execute(query).fetchall() + assert result is not None + assert len(result) == 1 + + + finally: + with test_db_engine.connect() as connection: + connection.execute(clear_tables_statement) @@ -294,22 +379,48 @@ def test_convert_group_name_to_id(test_db_engine): try: with test_db_engine.connect() as connection: - data = {"user_id": 1, "group_name": "test_group_name", "group_id": 1} + user_id =1 + + groups = [ + { + "group_name": "test group 1", + "group_id": 1 + }, + { + "group_name": "test group 2", + "group_id": 2 + }, + { + "group_name": "test group 3", + "group_id": 3 + }, + ] query_statements = [] query_statements.append(clear_tables_statement) - query_statements.append(get_user_insert_statement(data["user_id"])) - query_statements.append(get_user_group_insert_statement(data["user_id"], data["group_name"], data["group_id"])) + query_statements.append(get_user_insert_statement(user_id)) + + for group in groups: + query_statements.append(get_user_group_insert_statement(user_id, group["group_name"], group["group_id"])) connection.execute("".join(query_statements)) with GithubTaskSession(logger, test_db_engine) as session: controller = RepoLoadController(session) - group_id = controller.convert_group_name_to_id(data["user_id"], data["group_name"]) - assert group_id is not None - assert group_id == data["group_id"] + for group in groups: + assert controller.convert_group_name_to_id(user_id, group["group_name"]) == group["group_id"] + + # test invalid group name + assert controller.convert_group_name_to_id(user_id, "hello") is None + + # test invalid user id + assert controller.convert_group_name_to_id(user_id*2, groups[0]["group_name"]) is None + + # test invalid types + assert controller.convert_group_name_to_id(user_id, 5) is None + assert controller.convert_group_name_to_id("5", groups[0]["group_name"]) is None finally: with test_db_engine.connect() as connection: From f75e217b455e425fb74a1938d4e0d411763b9a58 Mon Sep 17 00:00:00 2001 From: Andrew Brain Date: Thu, 22 Dec 2022 12:04:17 -0600 Subject: [PATCH 017/150] Add more tests Signed-off-by: Andrew Brain --- .../test_helper_functions.py | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/tests/test_applicaton/test_repo_load_controller/test_helper_functions.py b/tests/test_applicaton/test_repo_load_controller/test_helper_functions.py index 90e983ae6e..a75a323b10 100644 --- a/tests/test_applicaton/test_repo_load_controller/test_helper_functions.py +++ b/tests/test_applicaton/test_repo_load_controller/test_helper_functions.py @@ -427,6 +427,85 @@ def test_convert_group_name_to_id(test_db_engine): connection.execute(clear_tables_statement) +def test_remove_user_group(test_db_engine): + + clear_tables = ["user_repos", "user_groups", "repo", "repo_groups", "users"] + clear_tables_statement = get_repo_related_delete_statements(clear_tables) + + try: + with test_db_engine.connect() as connection: + + user_id =1 + + groups = [ + { + "group_name": "test group 1", + "group_id": 1 + }, + { + "group_name": "test group 2", + "group_id": 2 + }, + { + "group_name": "test group 3", + "group_id": 3 + }, + { + "group_name": "test group 4", + "group_id": 4 + }, + { + "group_name": "test group 5", + "group_id": 5 + } + ] + + query_statements = [] + query_statements.append(clear_tables_statement) + query_statements.append(get_user_insert_statement(user_id)) + + for group in groups: + query_statements.append(get_user_group_insert_statement(user_id, group["group_name"], group["group_id"])) + + connection.execute("".join(query_statements)) + + with GithubTaskSession(logger, test_db_engine) as session: + + controller = RepoLoadController(session) + + assert controller.remove_user_group(user_id, "hello")["status"] == "WARNING: Trying to delete group that does not exist" + + i = 0 + while(i < len(groups)-2): + assert controller.remove_user_group(user_id, groups[i]["group_name"])["status"] == "Group deleted" + i += 1 + + + with test_db_engine.connect() as connection: + + query = s.text("""SELECT * FROM "augur_operations"."user_groups";""") + + result = connection.execute(query).fetchall() + assert result is not None + assert len(result) == len(groups)-i + + + while(i < len(groups)): + + assert controller.remove_user_group(user_id, groups[i]["group_name"])["status"] == "Group deleted" + i += 1 + + with test_db_engine.connect() as connection: + + query = s.text("""SELECT * FROM "augur_operations"."user_groups";""") + + result = connection.execute(query).fetchall() + assert result is not None + assert len(result) == 0 + + finally: + with test_db_engine.connect() as connection: + connection.execute(clear_tables_statement) From 3d24577ff18d33a36fcb60e42cd1f61dc887db1a Mon Sep 17 00:00:00 2001 From: Andrew Brain Date: Thu, 22 Dec 2022 12:16:01 -0600 Subject: [PATCH 018/150] Add more tests Signed-off-by: Andrew Brain --- .../test_helper_functions.py | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/tests/test_applicaton/test_repo_load_controller/test_helper_functions.py b/tests/test_applicaton/test_repo_load_controller/test_helper_functions.py index a75a323b10..c294ed791d 100644 --- a/tests/test_applicaton/test_repo_load_controller/test_helper_functions.py +++ b/tests/test_applicaton/test_repo_load_controller/test_helper_functions.py @@ -510,3 +510,68 @@ def test_remove_user_group(test_db_engine): +def test_get_user_groups(test_db_engine): + + clear_tables = ["user_repos", "user_groups", "repo", "repo_groups", "users"] + clear_tables_statement = get_repo_related_delete_statements(clear_tables) + + try: + with test_db_engine.connect() as connection: + + user_id =1 + + groups = [ + { + "group_name": "test group 1", + "group_id": 1 + }, + { + "group_name": "test group 2", + "group_id": 2 + }, + { + "group_name": "test group 3", + "group_id": 3 + }, + { + "group_name": "test group 4", + "group_id": 4 + }, + { + "group_name": "test group 5", + "group_id": 5 + } + ] + + query_statements = [] + query_statements.append(clear_tables_statement) + query_statements.append(get_user_insert_statement(user_id)) + + for group in groups: + query_statements.append(get_user_group_insert_statement(user_id, group["group_name"], group["group_id"])) + + connection.execute("".join(query_statements)) + + with GithubTaskSession(logger, test_db_engine) as session: + + controller = RepoLoadController(session) + + assert len(controller.get_user_groups(user_id)) == len(groups) + + + with test_db_engine.connect() as connection: + + user_group_delete_statement = get_user_group_delete_statement() + query = s.text(user_group_delete_statement) + + result = connection.execute(query) + + assert len(controller.get_user_groups(user_id)) == 0 + + finally: + with test_db_engine.connect() as connection: + connection.execute(clear_tables_statement) + + + + From 8e01729ca747c58deb3a4b4ddab4fd2afeab2b87 Mon Sep 17 00:00:00 2001 From: Andrew Brain Date: Thu, 22 Dec 2022 12:34:01 -0600 Subject: [PATCH 019/150] Add more tests Signed-off-by: Andrew Brain --- augur/util/repo_load_controller.py | 5 +- .../test_repo_load_controller/helper.py | 4 ++ .../test_helper_functions.py | 49 +++++++++++++++++++ 3 files changed, 55 insertions(+), 3 deletions(-) diff --git a/augur/util/repo_load_controller.py b/augur/util/repo_load_controller.py index ccecb93d2b..b58f9f20b6 100644 --- a/augur/util/repo_load_controller.py +++ b/augur/util/repo_load_controller.py @@ -246,10 +246,9 @@ def get_user_groups(self, user_id): return self.session.query(UserGroup).filter(UserGroup.user_id == user_id).all() def get_user_group_repos(self, group_id): + user_repos = self.session.query(UserRepo).filter(UserRepo.group_id == group_id).all() - user_repos = self.session.query(UserRepo).filter(UserRepo.user_id == user_id, UserRepo.group_id == group_id).all() - - return [user_repo.repo.repo_id for user_repo in user_repos] + return [user_repo.repo for user_repo in user_repos] def add_frontend_repo(self, url: List[str], user_id: int, group_name: str, group_id=None, valid_repo=False): diff --git a/tests/test_applicaton/test_repo_load_controller/helper.py b/tests/test_applicaton/test_repo_load_controller/helper.py index cea59fccf1..b05be747a7 100644 --- a/tests/test_applicaton/test_repo_load_controller/helper.py +++ b/tests/test_applicaton/test_repo_load_controller/helper.py @@ -93,6 +93,10 @@ def get_repo_insert_statement(repo_id, rg_id, repo_url="place holder url", repo_ return """INSERT INTO "augur_data"."repo" ("repo_id", "repo_group_id", "repo_git", "repo_path", "repo_name", "repo_added", "repo_status", "repo_type", "url", "owner_id", "description", "primary_language", "created_at", "forked_from", "updated_at", "repo_archived_date_collected", "repo_archived", "tool_source", "tool_version", "data_source", "data_collection_date") VALUES ({}, {}, '{}', NULL, NULL, '2022-08-15 21:08:07', '{}', '', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'CLI', '1.0', 'Git', '2022-08-15 21:08:07');""".format(repo_id, rg_id, repo_url, repo_status) +def get_user_repo_insert_statement(repo_id, group_id): + + return """INSERT INTO "augur_operations"."user_repos" ("repo_id", "group_id") VALUES ({}, {});""".format(repo_id, group_id) + def get_repo_group_insert_statement(rg_id): return """INSERT INTO "augur_data"."repo_groups" ("repo_group_id", "rg_name", "rg_description", "rg_website", "rg_recache", "rg_last_modified", "rg_type", "tool_source", "tool_version", "data_source", "data_collection_date") VALUES ({}, 'Default Repo Group', 'The default repo group created by the schema generation script', '', 0, '2019-06-03 15:55:20', 'GitHub Organization', 'load', 'one', 'git', '2019-06-05 13:36:25');""".format(rg_id) diff --git a/tests/test_applicaton/test_repo_load_controller/test_helper_functions.py b/tests/test_applicaton/test_repo_load_controller/test_helper_functions.py index c294ed791d..2492cc3cca 100644 --- a/tests/test_applicaton/test_repo_load_controller/test_helper_functions.py +++ b/tests/test_applicaton/test_repo_load_controller/test_helper_functions.py @@ -573,5 +573,54 @@ def test_get_user_groups(test_db_engine): connection.execute(clear_tables_statement) +def test_get_user_group_repos(test_db_engine): + + clear_tables = ["user_repos", "user_groups", "repo", "repo_groups", "users"] + clear_tables_statement = get_repo_related_delete_statements(clear_tables) + + try: + with test_db_engine.connect() as connection: + + user_id =1 + group_id = 1 + rg_id = 1 + group_name = "test_group 1" + repo_ids = [1, 2, 3, 4, 5] + repo_urls = ["url1", "url2", "url3", "url4", "url5"] + + query_statements = [] + query_statements.append(clear_tables_statement) + query_statements.append(get_user_insert_statement(user_id)) + query_statements.append(get_repo_group_insert_statement(rg_id)) + query_statements.append(get_user_group_insert_statement(user_id, group_name, group_id)) + for i in range(0, len(repo_ids)): + query_statements.append(get_repo_insert_statement(repo_ids[i], rg_id, repo_urls[i])) + query_statements.append(get_user_repo_insert_statement(repo_ids[i], group_id)) + + connection.execute("".join(query_statements)) + + with GithubTaskSession(logger, test_db_engine) as session: + + controller = RepoLoadController(session) + + result = controller.get_user_group_repos(group_id) + + assert len(result) == len(repo_ids) + assert set([repo.repo_id for repo in result]) == set(repo_ids) + + with test_db_engine.connect() as connection: + + user_repo_delete_statement = get_user_repo_delete_statement() + query = s.text(user_repo_delete_statement) + + result = connection.execute(query) + + assert len(controller.get_user_group_repos(group_id)) == 0 + + finally: + with test_db_engine.connect() as connection: + connection.execute(clear_tables_statement) + + From 30ea9ce83d4cfbd3cef0feb2d498fb2f47a23837 Mon Sep 17 00:00:00 2001 From: Andrew Brain Date: Mon, 2 Jan 2023 12:46:16 -0600 Subject: [PATCH 020/150] Add more tests to repo load controller Signed-off-by: Andrew Brain --- augur/util/repo_load_controller.py | 2 +- .../test_adding_orgs.py | 2 +- .../test_helper_functions.py | 48 +++++++++++++++++++ 3 files changed, 50 insertions(+), 2 deletions(-) diff --git a/augur/util/repo_load_controller.py b/augur/util/repo_load_controller.py index b58f9f20b6..b2fe271da9 100644 --- a/augur/util/repo_load_controller.py +++ b/augur/util/repo_load_controller.py @@ -404,7 +404,7 @@ def get_user_repo_ids(self, user_id: int) -> List[int]: list of repo ids """ - user_groups = session.query(UserGroup).filter(UserGroup.user_id).all() + user_groups = self.session.query(UserGroup).filter(UserGroup.user_id == user_id).all() all_repo_ids = set() for group in user_groups: diff --git a/tests/test_applicaton/test_repo_load_controller/test_adding_orgs.py b/tests/test_applicaton/test_repo_load_controller/test_adding_orgs.py index 985b663016..d1fda916e0 100644 --- a/tests/test_applicaton/test_repo_load_controller/test_adding_orgs.py +++ b/tests/test_applicaton/test_repo_load_controller/test_adding_orgs.py @@ -10,7 +10,7 @@ logger = logging.getLogger(__name__) -VALID_ORG = {"org": "CDCgov", "repo_count": 241} +VALID_ORG = {"org": "CDCgov", "repo_count": 246} def test_add_frontend_org_with_invalid_org(test_db_engine): diff --git a/tests/test_applicaton/test_repo_load_controller/test_helper_functions.py b/tests/test_applicaton/test_repo_load_controller/test_helper_functions.py index 2492cc3cca..c52a9ad839 100644 --- a/tests/test_applicaton/test_repo_load_controller/test_helper_functions.py +++ b/tests/test_applicaton/test_repo_load_controller/test_helper_functions.py @@ -621,6 +621,54 @@ def test_get_user_group_repos(test_db_engine): with test_db_engine.connect() as connection: connection.execute(clear_tables_statement) +def test_get_user_repo_ids(test_db_engine): + + clear_tables = ["user_repos", "user_groups", "repo", "repo_groups", "users"] + clear_tables_statement = get_repo_related_delete_statements(clear_tables) + + try: + with test_db_engine.connect() as connection: + + user_id =1 + group_id = 1 + rg_id = 1 + group_name = "test_group 1" + repo_ids = [1, 2, 3, 4, 5] + repo_urls = ["url1", "url2", "url3", "url4", "url5"] + + query_statements = [] + query_statements.append(clear_tables_statement) + query_statements.append(get_user_insert_statement(user_id)) + query_statements.append(get_repo_group_insert_statement(rg_id)) + query_statements.append(get_user_group_insert_statement(user_id, group_name, group_id)) + for i in range(0, len(repo_ids)): + query_statements.append(get_repo_insert_statement(repo_ids[i], rg_id, repo_urls[i])) + query_statements.append(get_user_repo_insert_statement(repo_ids[i], group_id)) + + connection.execute("".join(query_statements)) + + with GithubTaskSession(logger, test_db_engine) as session: + + controller = RepoLoadController(session) + + result = controller.get_user_repo_ids(user_id) + + assert len(result) == len(repo_ids) + assert set(result) == set(repo_ids) + + with test_db_engine.connect() as connection: + + user_repo_delete_statement = get_user_repo_delete_statement() + query = s.text(user_repo_delete_statement) + + result = connection.execute(query) + + assert len(controller.get_user_group_repos(group_id)) == 0 + + finally: + with test_db_engine.connect() as connection: + connection.execute(clear_tables_statement) + From 6ee51c182fab757f352786d88484036cf0ff3d8a Mon Sep 17 00:00:00 2001 From: Andrew Brain Date: Mon, 2 Jan 2023 12:59:27 -0600 Subject: [PATCH 021/150] Add more tests to repo load controller Signed-off-by: Andrew Brain --- .../test_helper_functions.py | 63 ++++++++++++++++--- 1 file changed, 54 insertions(+), 9 deletions(-) diff --git a/tests/test_applicaton/test_repo_load_controller/test_helper_functions.py b/tests/test_applicaton/test_repo_load_controller/test_helper_functions.py index c52a9ad839..cfd69521d4 100644 --- a/tests/test_applicaton/test_repo_load_controller/test_helper_functions.py +++ b/tests/test_applicaton/test_repo_load_controller/test_helper_functions.py @@ -518,7 +518,9 @@ def test_get_user_groups(test_db_engine): try: with test_db_engine.connect() as connection: - user_id =1 + user_id_1 = 1 + user_id_2 = 2 + groups = [ { @@ -545,10 +547,13 @@ def test_get_user_groups(test_db_engine): query_statements = [] query_statements.append(clear_tables_statement) - query_statements.append(get_user_insert_statement(user_id)) + query_statements.append(get_user_insert_statement(user_id_1)) + + # add user with no user groups + query_statements.append(get_user_insert_statement(user_id_2, username="hello", email="hello@gmail.com")) for group in groups: - query_statements.append(get_user_group_insert_statement(user_id, group["group_name"], group["group_id"])) + query_statements.append(get_user_group_insert_statement(user_id_1, group["group_name"], group["group_id"])) connection.execute("".join(query_statements)) @@ -556,7 +561,9 @@ def test_get_user_groups(test_db_engine): controller = RepoLoadController(session) - assert len(controller.get_user_groups(user_id)) == len(groups) + assert len(controller.get_user_groups(user_id_1)) == len(groups) + + assert len(controller.get_user_groups(user_id_2)) == 0 with test_db_engine.connect() as connection: @@ -565,8 +572,6 @@ def test_get_user_groups(test_db_engine): query = s.text(user_group_delete_statement) result = connection.execute(query) - - assert len(controller.get_user_groups(user_id)) == 0 finally: with test_db_engine.connect() as connection: @@ -582,7 +587,9 @@ def test_get_user_group_repos(test_db_engine): with test_db_engine.connect() as connection: user_id =1 + user_id_2 = 2 group_id = 1 + group_id_2 = 2 rg_id = 1 group_name = "test_group 1" repo_ids = [1, 2, 3, 4, 5] @@ -590,9 +597,17 @@ def test_get_user_group_repos(test_db_engine): query_statements = [] query_statements.append(clear_tables_statement) + + # add user with a group that has multiple repos query_statements.append(get_user_insert_statement(user_id)) - query_statements.append(get_repo_group_insert_statement(rg_id)) query_statements.append(get_user_group_insert_statement(user_id, group_name, group_id)) + + # add user with a group that has no repos + query_statements.append(get_user_insert_statement(user_id_2, username="hello", email="hello@gmail.com")) + query_statements.append(get_user_group_insert_statement(user_id_2, group_name, group_id_2)) + + query_statements.append(get_repo_group_insert_statement(rg_id)) + for i in range(0, len(repo_ids)): query_statements.append(get_repo_insert_statement(repo_ids[i], rg_id, repo_urls[i])) query_statements.append(get_user_repo_insert_statement(repo_ids[i], group_id)) @@ -608,6 +623,11 @@ def test_get_user_group_repos(test_db_engine): assert len(result) == len(repo_ids) assert set([repo.repo_id for repo in result]) == set(repo_ids) + result = controller.get_user_group_repos(group_id_2) + + assert len(result) == 0 + + with test_db_engine.connect() as connection: user_repo_delete_statement = get_user_repo_delete_statement() @@ -621,7 +641,8 @@ def test_get_user_group_repos(test_db_engine): with test_db_engine.connect() as connection: connection.execute(clear_tables_statement) -def test_get_user_repo_ids(test_db_engine): + +def test_get_user_group_repos(test_db_engine): clear_tables = ["user_repos", "user_groups", "repo", "repo_groups", "users"] clear_tables_statement = get_repo_related_delete_statements(clear_tables) @@ -630,7 +651,9 @@ def test_get_user_repo_ids(test_db_engine): with test_db_engine.connect() as connection: user_id =1 + user_id_2 = 2 group_id = 1 + group_id_2 = 2 rg_id = 1 group_name = "test_group 1" repo_ids = [1, 2, 3, 4, 5] @@ -638,9 +661,16 @@ def test_get_user_repo_ids(test_db_engine): query_statements = [] query_statements.append(clear_tables_statement) + + # add user with a group that has multiple repos query_statements.append(get_user_insert_statement(user_id)) - query_statements.append(get_repo_group_insert_statement(rg_id)) query_statements.append(get_user_group_insert_statement(user_id, group_name, group_id)) + + # add user with a group that has no repos + query_statements.append(get_user_insert_statement(user_id_2, username="hello", email="hello@gmail.com")) + + query_statements.append(get_repo_group_insert_statement(rg_id)) + for i in range(0, len(repo_ids)): query_statements.append(get_repo_insert_statement(repo_ids[i], rg_id, repo_urls[i])) query_statements.append(get_user_repo_insert_statement(repo_ids[i], group_id)) @@ -651,11 +681,26 @@ def test_get_user_repo_ids(test_db_engine): controller = RepoLoadController(session) + # test user with a group that has multiple repos result = controller.get_user_repo_ids(user_id) assert len(result) == len(repo_ids) assert set(result) == set(repo_ids) + + # test user without any groups or repos + result = controller.get_user_repo_ids(user_id_2) + + assert len(result) == 0 + + query_statements.append(get_user_group_insert_statement(user_id_2, group_name, group_id_2)) + + + # test user with a group that doesn't have any repos + result = controller.get_user_repo_ids(user_id_2) + + assert len(result) == 0 + with test_db_engine.connect() as connection: user_repo_delete_statement = get_user_repo_delete_statement() From 17de8ebdf1cc4583bac0c3ac2156e55f8d719f01 Mon Sep 17 00:00:00 2001 From: Andrew Brain Date: Tue, 3 Jan 2023 10:50:14 -0600 Subject: [PATCH 022/150] Fix deleting user errors Signed-off-by: Andrew Brain --- augur/api/routes/user.py | 12 ++++++++---- augur/util/repo_load_controller.py | 4 ---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/augur/api/routes/user.py b/augur/api/routes/user.py index b228ccfdf3..3640cc817f 100644 --- a/augur/api/routes/user.py +++ b/augur/api/routes/user.py @@ -122,9 +122,13 @@ def delete_user(): if user is None: return jsonify({"status": "User does not exist"}) - user_repos = session.query(UserRepo).filter(UserRepo.user_id == user.user_id).all() - for repo in user_repos: - session.delete(repo) + for group in user.groups: + user_repos_list = group.repos + + for user_repo_entry in user_repos_list: + session.delete(user_repo_entry) + + session.delete(group) session.delete(user) session.commit() @@ -260,7 +264,7 @@ def remove_user_group(): with GithubTaskSession(logger) as session: - if username is None or group_name is None + if username is None or group_name is None: return jsonify({"status": "Missing argument"}), 400 user = session.query(User).filter(User.login_name == username).first() diff --git a/augur/util/repo_load_controller.py b/augur/util/repo_load_controller.py index b2fe271da9..de8c4baa21 100644 --- a/augur/util/repo_load_controller.py +++ b/augur/util/repo_load_controller.py @@ -452,7 +452,3 @@ def parse_org_url(self, url): return owner except IndexError: return None - - - - From 965f41551a4c3714f97269b40c708dce307063c9 Mon Sep 17 00:00:00 2001 From: Andrew Brain Date: Tue, 3 Jan 2023 11:10:05 -0600 Subject: [PATCH 023/150] Small fixes to user endpoints Signed-off-by: Andrew Brain --- augur/api/routes/user.py | 2 +- augur/util/repo_load_controller.py | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/augur/api/routes/user.py b/augur/api/routes/user.py index 3640cc817f..af1b862a3f 100644 --- a/augur/api/routes/user.py +++ b/augur/api/routes/user.py @@ -317,7 +317,7 @@ def remove_user_repo(): with GithubTaskSession(logger) as session: - if username is None or repo is None or group_name is None: + if username is None or repo_id is None or group_name is None: return jsonify({"status": "Missing argument"}), 400 user = session.query(User).filter( User.login_name == username).first() diff --git a/augur/util/repo_load_controller.py b/augur/util/repo_load_controller.py index de8c4baa21..6958cb2fa9 100644 --- a/augur/util/repo_load_controller.py +++ b/augur/util/repo_load_controller.py @@ -218,11 +218,14 @@ def remove_user_group(self, user_id, group_name): if group_id is None: return {"status": "WARNING: Trying to delete group that does not exist"} + group = self.session.query(UserGroup).filter(UserGroup.group_id == group_id).one() + # delete rows from user repos with group_id - self.session.query(UserRepo).filter(UserRepo.group_id == group_id).delete() - + for repo in group.repos: + self.session.delete(repo) + # delete group from user groups table - self.session.query(UserGroup).filter(UserGroup.group_id == group_id).delete() + self.session.delete(group) self.session.commit() From 215449341bb5c1075ea7b16bf836055f846a719e Mon Sep 17 00:00:00 2001 From: Isaac Milarsky Date: Tue, 3 Jan 2023 12:26:43 -0600 Subject: [PATCH 024/150] scaling fix for repo_move Signed-off-by: Isaac Milarsky --- augur/tasks/github/detect_move/tasks.py | 11 +++++++---- augur/tasks/start_tasks.py | 14 +++++++------- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/augur/tasks/github/detect_move/tasks.py b/augur/tasks/github/detect_move/tasks.py index 475ebbc523..9277a90f02 100644 --- a/augur/tasks/github/detect_move/tasks.py +++ b/augur/tasks/github/detect_move/tasks.py @@ -6,10 +6,13 @@ @celery.task -def detect_github_repo_move(repo_git: str) -> None: +def detect_github_repo_move(repo_git_identifiers : str) -> None: logger = logging.getLogger(detect_github_repo_move.__name__) with GithubTaskSession(logger) as session: - query = session.query(Repo).filter(Repo.repo_git == repo_git) - repo = execute_session_query(query, 'one') - ping_github_for_repo_move(session, repo) \ No newline at end of file + #Ping each repo with the given repo_git to make sure + #that they are still in place. + for repo_git in repo_git_identifiers: + query = session.query(Repo).filter(Repo.repo_git == repo_git) + repo = execute_session_query(query, 'one') + ping_github_for_repo_move(session, repo) \ No newline at end of file diff --git a/augur/tasks/start_tasks.py b/augur/tasks/start_tasks.py index f5a48e3112..b65290c77c 100644 --- a/augur/tasks/start_tasks.py +++ b/augur/tasks/start_tasks.py @@ -39,19 +39,19 @@ def prelim_phase(): logger = logging.getLogger(prelim_phase.__name__) - tasks_with_repo_domain = [] + tasks_with_repo_domain = None + + with DatabaseSession(logger) as session: query = session.query(Repo) repos = execute_session_query(query, 'all') + repo_git_list = [repo.repo_git for repo in repos] + + tasks_with_repo_domain = create_grouped_task_load(dataList=repo_git_list,task=detect_github_repo_move) - for repo in repos: - tasks_with_repo_domain.append(detect_github_repo_move.si(repo.repo_git)) - #preliminary_task_list = [detect_github_repo_move.si()] - preliminary_tasks = group(*tasks_with_repo_domain) - #preliminary_tasks.apply_async() - return preliminary_tasks + return tasks_with_repo_domain def repo_collect_phase(): logger = logging.getLogger(repo_collect_phase.__name__) From 427b0da7893f460f22df1e94c7d553f2ca9be00b Mon Sep 17 00:00:00 2001 From: Isaac Milarsky Date: Tue, 3 Jan 2023 14:27:50 -0600 Subject: [PATCH 025/150] syntax Signed-off-by: Isaac Milarsky --- augur/tasks/start_tasks.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/augur/tasks/start_tasks.py b/augur/tasks/start_tasks.py index b65290c77c..c3d29c4535 100644 --- a/augur/tasks/start_tasks.py +++ b/augur/tasks/start_tasks.py @@ -38,20 +38,13 @@ def prelim_phase(): logger = logging.getLogger(prelim_phase.__name__) - - tasks_with_repo_domain = None - - with DatabaseSession(logger) as session: query = session.query(Repo) repos = execute_session_query(query, 'all') repo_git_list = [repo.repo_git for repo in repos] - tasks_with_repo_domain = create_grouped_task_load(dataList=repo_git_list,task=detect_github_repo_move) - - - return tasks_with_repo_domain + return create_grouped_task_load(dataList=repo_git_list,task=detect_github_repo_move) def repo_collect_phase(): logger = logging.getLogger(repo_collect_phase.__name__) From 3db3aadf758a757c6214663285964aa58d01fc95 Mon Sep 17 00:00:00 2001 From: Andrew Brain Date: Tue, 3 Jan 2023 14:38:29 -0600 Subject: [PATCH 026/150] Add more tests for coverage Signed-off-by: Andrew Brain --- augur/util/repo_load_controller.py | 53 ++++------- .../test_adding_orgs.py | 24 ++++- .../test_adding_repos.py | 93 +++++++++++++++++++ .../test_helper_functions.py | 7 ++ 4 files changed, 135 insertions(+), 42 deletions(-) diff --git a/augur/util/repo_load_controller.py b/augur/util/repo_load_controller.py index 6958cb2fa9..4628c40a0f 100644 --- a/augur/util/repo_load_controller.py +++ b/augur/util/repo_load_controller.py @@ -41,14 +41,6 @@ def is_valid_repo(self, url: str) -> bool: if not owner or not repo: return False - if repo.endswith(".git"): - # removes .git - repo = repo[:-4] - - if repo.endswith("/"): - # reomves / - repo = repo[:-1] - url = REPO_ENDPOINT.format(owner, repo) attempts = 0 @@ -84,10 +76,6 @@ def retrieve_org_repos(self, url: str) -> List[str]: if not owner: return False - if owner.endswith("/"): - # reomves / - owner = owner[:-1] - url = ORG_REPOS_ENDPOINT.format(owner) repos = [] @@ -113,10 +101,7 @@ def is_valid_repo_group_id(self, repo_group_id): except (s.orm.exc.NoResultFound, s.orm.exc.MultipleResultsFound): return False - if result and result.repo_group_id == repo_group_id: - return True - - return False + return True def add_repo_row(self, url: str, repo_group_id: int, tool_source): """Add a repo to the repo table. @@ -288,6 +273,9 @@ def add_frontend_repo(self, url: List[str], user_id: int, group_name: str, group def remove_frontend_repo(self, repo_id, user_id, group_name): + if not isinstance(repo_id, int) or not isinstance(user_id, int) or not isinstance(group_name, str): + return {"status": "Invalid input params"} + group_id = self.convert_group_name_to_id(user_id, group_name) if group_id is None: return {"status": "Invalid group name"} @@ -315,11 +303,6 @@ def add_frontend_org(self, url: List[str], user_id: int, group_name: int): if not repos: return {"status": "Invalid org", "org_url": url} - - org_name = self.parse_org_url(url) - if not org_name: - return {"status": "Invalid org", "org_url": url} - # try to get the repo group with this org name # if it does not exist create one failed_repos = [] @@ -356,7 +339,7 @@ def add_cli_repo(self, repo_data: Dict[str, Any], valid_repo=False): if not repo_id: logger.warning(f"Invalid repo group id specified for {url}, skipping.") - return + return {"status": f"Invalid repo group id specified for {url}, skipping."} self.add_repo_to_user_group(repo_id) @@ -373,14 +356,15 @@ def add_cli_org(self, org_name): if not repos: print( f"No organization with name {org_name} could be found") - return + return {"status": "No organization found"} # check if the repo group already exists query = self.session.query(RepoGroup).filter(RepoGroup.rg_name == org_name) rg = execute_session_query(query, 'first') if rg: print(f"{rg.rg_name} is already a repo group") - return + + return {"status": "Already a repo group"} print(f'Organization "{org_name}" found') @@ -395,6 +379,8 @@ def add_cli_org(self, org_name): logger.info( f"Adding {repo_url}") self.add_cli_repo({"url": repo_url, "repo_group_id": repo_group_id}, valid_repo=True) + + return {"status": "Org added"} def get_user_repo_ids(self, user_id: int) -> List[int]: @@ -433,13 +419,11 @@ def parse_repo_url(self, url): capturing_groups = result.groups() - try: - owner = capturing_groups[0] - repo = capturing_groups[1] + + owner = capturing_groups[0] + repo = capturing_groups[1] - return owner, repo - except IndexError: - return None, None + return owner, repo def parse_org_url(self, url): @@ -448,10 +432,5 @@ def parse_org_url(self, url): if not result: return None - capturing_groups = result.groups() - - try: - owner = capturing_groups[0] - return owner - except IndexError: - return None + # if the result is not None then the groups should be valid so we don't worry about index errors here + return result.groups()[0] diff --git a/tests/test_applicaton/test_repo_load_controller/test_adding_orgs.py b/tests/test_applicaton/test_repo_load_controller/test_adding_orgs.py index d1fda916e0..8e9b104b38 100644 --- a/tests/test_applicaton/test_repo_load_controller/test_adding_orgs.py +++ b/tests/test_applicaton/test_repo_load_controller/test_adding_orgs.py @@ -35,10 +35,16 @@ def test_add_frontend_org_with_invalid_org(test_db_engine): add_keys_to_test_db(test_db_engine) with GithubTaskSession(logger, test_db_engine) as session: + controller = RepoLoadController(session) + url = f"https://github.com/{data['org_name']}/" - result = RepoLoadController(session).add_frontend_org(url, data["user_id"], data["user_group_name"]) + result = controller.add_frontend_org(url, data["user_id"], data["user_group_name"]) assert result["status"] == "Invalid org" + # test with invalid group name + result = controller.add_frontend_org(url, data["user_id"], "Invalid group name") + assert result["status"] == "Invalid group name" + with test_db_engine.connect() as connection: result = get_repos(connection) @@ -115,8 +121,15 @@ def test_add_cli_org_with_valid_org(test_db_engine): with GithubTaskSession(logger, test_db_engine) as session: - result = RepoLoadController(session).add_cli_org(data["org_name"]) - print(result) + controller = RepoLoadController(session) + + result = controller.add_cli_org(data["org_name"]) + + assert result["status"] == "Org added" + + result2 = controller.add_cli_org("Invalid org") + assert result2["status"] == "No organization found" + with test_db_engine.connect() as connection: @@ -130,5 +143,6 @@ def test_add_cli_org_with_valid_org(test_db_engine): finally: with test_db_engine.connect() as connection: - pass - # connection.execute(clear_tables_statement) + connection.execute(clear_tables_statement) + + diff --git a/tests/test_applicaton/test_repo_load_controller/test_adding_repos.py b/tests/test_applicaton/test_repo_load_controller/test_adding_repos.py index ebef2280e4..7f65b1e017 100644 --- a/tests/test_applicaton/test_repo_load_controller/test_adding_repos.py +++ b/tests/test_applicaton/test_repo_load_controller/test_adding_repos.py @@ -50,6 +50,37 @@ def test_add_frontend_repos_with_invalid_repo(test_db_engine): connection.execute(clear_tables_statement) +def test_add_cli_repos_with_invalid_repo_group_id(test_db_engine): + + clear_tables = ["user_repos", "user_groups", "repo", "repo_groups", "users", "config"] + clear_tables_statement = get_repo_related_delete_statements(clear_tables) + + try: + with test_db_engine.connect() as connection: + + data = {"user_id": CLI_USER_ID, "repo_group_id": 5, "org_name": "operate-first", "repo_name": "operate-first-twitter", "user_group_name": "test_group", "user_group_id": 1} + url = f"https://github.com/{data['org_name']}/{data['repo_name']}" + + query_statements = [] + query_statements.append(clear_tables_statement) + + connection.execute("".join(query_statements)) + + add_keys_to_test_db(test_db_engine) + + with GithubTaskSession(logger, test_db_engine) as session: + + repo_data = {"url": url, "repo_group_id": 5} + + controller = RepoLoadController(session) + result = controller.add_cli_repo(repo_data) + assert result["status"] == f"Invalid repo group id specified for {repo_data['url']}, skipping." + + finally: + with test_db_engine.connect() as connection: + connection.execute(clear_tables_statement) + + @@ -125,8 +156,12 @@ def test_add_frontend_repos_with_duplicates(test_db_engine): result = controller.add_frontend_repo(url, data["user_id"], data["user_group_name"]) result2 = controller.add_frontend_repo(url, data["user_id"], data["user_group_name"]) + # add repo with invalid group name + result3 = controller.add_frontend_repo(url, data["user_id"], "Invalid group name") + assert result["status"] == "Repo Added" assert result2["status"] == "Repo Added" + assert result3["status"] == "Invalid group name" with test_db_engine.connect() as connection: @@ -138,3 +173,61 @@ def test_add_frontend_repos_with_duplicates(test_db_engine): finally: with test_db_engine.connect() as connection: connection.execute(clear_tables_statement) + + + + +def test_remove_frontend_repo(test_db_engine): + + clear_tables = ["user_repos", "user_groups", "repo", "repo_groups", "users", "config"] + clear_tables_statement = get_repo_related_delete_statements(clear_tables) + + try: + with test_db_engine.connect() as connection: + + url = "https://github.com/operate-first/operate-first-twitter" + + data = {"user_id": 2, "repo_id": 5, "repo_group_id": DEFAULT_REPO_GROUP_IDS[0], "user_group_name": "test_group", "user_group_id": 1} + + query_statements = [] + query_statements.append(clear_tables_statement) + query_statements.append(get_repo_group_insert_statement(data["repo_group_id"])) + query_statements.append(get_user_insert_statement(data["user_id"])) + query_statements.append(get_user_group_insert_statement(data["user_id"], data["user_group_name"], data["user_group_id"])) + query_statements.append(get_repo_insert_statement(data["repo_id"], data["repo_group_id"], repo_url="url")) + query_statements.append(get_user_repo_insert_statement(data["repo_id"], data["user_group_id"])) + + connection.execute("".join(query_statements)) + + add_keys_to_test_db(test_db_engine) + + with GithubTaskSession(logger, test_db_engine) as session: + + controller = RepoLoadController(session) + + # remove valid user repo + result = controller.remove_frontend_repo(data["repo_id"], data["user_id"], data["user_group_name"]) + assert result["status"] == "Repo Removed" + + with test_db_engine.connect() as connection: + + repos = get_user_repos(connection) + assert len(repos) == 0 + + # remove invalid group + result = controller.remove_frontend_repo(data["repo_id"], data["user_id"], "invalid group") + assert result["status"] == "Invalid group name" + + # pass invalid data types + result = controller.remove_frontend_repo("5", data["user_id"], data["user_group_name"]) + assert result["status"] == "Invalid input params" + + result = controller.remove_frontend_repo(data["repo_id"], "1", data["user_group_name"]) + assert result["status"] == "Invalid input params" + + result = controller.remove_frontend_repo(data["repo_id"], data["user_id"], 5) + assert result["status"] == "Invalid input params" + + finally: + with test_db_engine.connect() as connection: + connection.execute(clear_tables_statement) \ No newline at end of file diff --git a/tests/test_applicaton/test_repo_load_controller/test_helper_functions.py b/tests/test_applicaton/test_repo_load_controller/test_helper_functions.py index cfd69521d4..9034b42a84 100644 --- a/tests/test_applicaton/test_repo_load_controller/test_helper_functions.py +++ b/tests/test_applicaton/test_repo_load_controller/test_helper_functions.py @@ -59,6 +59,7 @@ def test_is_valid_repo(): assert controller.is_valid_repo("https://github.com/chaoss/augur") is True assert controller.is_valid_repo("https://github.com/chaoss/augur/") is True assert controller.is_valid_repo("https://github.com/chaoss/augur.git") is True + assert controller.is_valid_repo("https://github.com/chaoss/augur/") is True def test_is_valid_repo_group_id(test_db_engine): @@ -436,6 +437,8 @@ def test_remove_user_group(test_db_engine): with test_db_engine.connect() as connection: user_id =1 + repo_id = 1 + rg_id = 1 groups = [ { @@ -467,6 +470,10 @@ def test_remove_user_group(test_db_engine): for group in groups: query_statements.append(get_user_group_insert_statement(user_id, group["group_name"], group["group_id"])) + query_statements.append(get_repo_group_insert_statement(rg_id)) + query_statements.append(get_repo_insert_statement(repo_id, rg_id)) + query_statements.append(get_user_repo_insert_statement(repo_id, groups[0]["group_id"])) + connection.execute("".join(query_statements)) with GithubTaskSession(logger, test_db_engine) as session: From 167ca50809240938769d86e7f47047ee492d9fa2 Mon Sep 17 00:00:00 2001 From: Andrew Brain Date: Tue, 3 Jan 2023 18:52:54 -0600 Subject: [PATCH 027/150] Add more endpoints to get the group and repo data for the frontend Signed-off-by: Andrew Brain --- augur/api/routes/user.py | 164 ++++++++++++++++++++++++++++++++++----- 1 file changed, 143 insertions(+), 21 deletions(-) diff --git a/augur/api/routes/user.py b/augur/api/routes/user.py index af1b862a3f..a8bf8bde11 100644 --- a/augur/api/routes/user.py +++ b/augur/api/routes/user.py @@ -7,6 +7,8 @@ import requests import json import os +import base64 +import pandas as pd from flask import request, Response, jsonify from werkzeug.security import generate_password_hash, check_password_hash from sqlalchemy.sql import text @@ -182,26 +184,6 @@ def update_user(): return jsonify({"status": "Missing argument"}), 400 - @server.app.route(f"/{AUGUR_API_VERSION}/user/repos", methods=['GET', 'POST']) - def user_repos(): - if not development and not request.is_secure: - return generate_upgrade_request() - - username = request.args.get("username") - - with DatabaseSession(logger) as session: - - if username is None: - return jsonify({"status": "Missing argument"}), 400 - user = session.query(User).filter(User.login_name == username).first() - if user is None: - return jsonify({"status": "User does not exist"}) - - repo_load_controller = RepoLoadController(gh_session=session) - - repo_ids = repo_load_controller.get_user_repo_ids(user.user_id) - - return jsonify({"status": "success", "repo_ids": repo_ids}) @server.app.route(f"/{AUGUR_API_VERSION}/user/add_repo", methods=['GET', 'POST']) def add_user_repo(): @@ -331,4 +313,144 @@ def remove_user_repo(): return jsonify(result) - + + @server.app.route(f"/{AUGUR_API_VERSION}/user/group_repos", methods=['GET', 'POST']) + def group_repos(): + if not development and not request.is_secure: + return generate_upgrade_request() + + username = request.args.get("username") + group_name = request.args.get("group_name") + page = request.args.get("page") + page_size = request.args.get("page_size") + sort = request.args.get("sort") + direction = request.args.get("direction") + + if (not username) or (not group_name) or (not page) or (not page_size) or (sort and not direction) or (not sort and direction): + return jsonify({"status": "Missing argument"}), 400 + + if direction and direction != "ASC" and direction != "DESC": + return {"status": "Invalid direction"} + + try: + page = int(page) + page_size = int(page_size) + except ValueError: + return jsonify({"status": "Page size and page should be integers"}), 400 + + if page < 0 or page_size < 0: + return jsonify({"status": "Page size and page should be postive"}), 400 + + + with DatabaseSession(logger) as session: + + controller = RepoLoadController(session) + + user = session.query(User).filter(User.login_name == username).first() + + group_id = controller.convert_group_name_to_id(user.user_id, group_name) + if group_id is None: + return jsonify({"status": "Group does not exist"}), 400 + + + order_by = sort if sort else "repo_id" + order_direction = direction if direction else "ASC" + + get_page_of_repos_sql = text(f""" + SELECT + augur_data.repo.repo_id, + augur_data.repo.repo_name, + augur_data.repo.description, + augur_data.repo.repo_git AS url, + augur_data.repo.repo_status, + a.commits_all_time, + b.issues_all_time, + rg_name, + augur_data.repo.repo_group_id + FROM + augur_data.repo + LEFT OUTER JOIN augur_data.api_get_all_repos_commits a ON augur_data.repo.repo_id = a.repo_id + LEFT OUTER JOIN augur_data.api_get_all_repos_issues b ON augur_data.repo.repo_id = b.repo_id + JOIN augur_operations.user_repos ON augur_data.repo.repo_id = augur_operations.user_repos.repo_id + JOIN augur_data.repo_groups ON augur_data.repo.repo_group_id = augur_data.repo_groups.repo_group_id + WHERE augur_operations.user_repos.group_id = {group_id} + ORDER BY {order_by} {order_direction} + LIMIT {page_size} + OFFSET {page*page_size}; + """) + + results = pd.read_sql(get_page_of_repos_sql, create_database_engine()) + results['url'] = results['url'].apply(lambda datum: datum.split('//')[1]) + + b64_urls = [] + for i in results.index: + b64_urls.append(base64.b64encode((results.at[i, 'url']).encode())) + results['base64_url'] = b64_urls + + data = results.to_json(orient="records", date_format='iso', date_unit='ms') + return Response(response=data, + status=200, + mimetype="application/json") + + + + @server.app.route(f"/{AUGUR_API_VERSION}/user/group_repo_count", methods=['GET', 'POST']) + def group_repo_count(): + if not development and not request.is_secure: + return generate_upgrade_request() + + username = request.args.get("username") + group_name = request.args.get("group_name") + + if (not username) or (not group_name): + return jsonify({"status": "Missing argument"}), 400 + + with DatabaseSession(logger) as session: + + controller = RepoLoadController(session) + + user = session.query(User).filter(User.login_name == username).first() + + group_id = controller.convert_group_name_to_id(user.user_id, group_name) + if group_id is None: + return jsonify({"status": "Group does not exist"}), 400 + + get_page_of_repos_sql = text(f""" + SELECT + count(*) + FROM + augur_data.repo + LEFT OUTER JOIN augur_data.api_get_all_repos_commits a ON augur_data.repo.repo_id = a.repo_id + LEFT OUTER JOIN augur_data.api_get_all_repos_issues b ON augur_data.repo.repo_id = b.repo_id + JOIN augur_operations.user_repos ON augur_data.repo.repo_id = augur_operations.user_repos.repo_id + JOIN augur_data.repo_groups ON augur_data.repo.repo_group_id = augur_data.repo_groups.repo_group_id + WHERE augur_operations.user_repos.group_id = {group_id} + """) + + result = session.fetchall_data_from_sql_text(get_page_of_repos_sql) + + return jsonify({"repos": result[0]["count"]}), 200 + + + @server.app.route(f"/{AUGUR_API_VERSION}/user/groups", methods=['GET', 'POST']) + def get_user_groups(): + if not development and not request.is_secure: + return generate_upgrade_request() + + username = request.args.get("username") + + if not username: + return jsonify({"status": "Missing argument"}), 400 + + with DatabaseSession(logger) as session: + + controller = RepoLoadController(session) + + user = session.query(User).filter(User.login_name == username).first() + + user_groups = controller.get_user_groups(user.user_id) + + group_names = [group.name for group in user_groups] + + return jsonify({"group_names": group_names}), 200 + From f65a88b4e49bd493f529f67f291d4cee0e5ac43a Mon Sep 17 00:00:00 2001 From: Ulincsys <28362836a@gmail.com> Date: Wed, 4 Jan 2023 05:49:29 -0600 Subject: [PATCH 028/150] Add documentation and update User endpoints: - Make ancillary arguments optional for group_repos - Add documentation clarifying new repo group endpoints --- augur/api/routes/user.py | 65 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 60 insertions(+), 5 deletions(-) diff --git a/augur/api/routes/user.py b/augur/api/routes/user.py index a8bf8bde11..77987168e5 100644 --- a/augur/api/routes/user.py +++ b/augur/api/routes/user.py @@ -316,21 +316,48 @@ def remove_user_repo(): @server.app.route(f"/{AUGUR_API_VERSION}/user/group_repos", methods=['GET', 'POST']) def group_repos(): + """Select repos from a user group by name + + Arguments + ---------- + username : str + The username of the user making the request + group_name : str + The name of the group to select + page : int = 0 -> [>= 0] + The page offset to use for pagination (optional) + page_size : int = 25 -> [> 0] + The number of result per page (optional) + sort : str + The name of the column to sort the data by (optional) + direction : str = "ASC" -> ["ASC" | "DESC"] + The direction to be used for sorting (optional) + + Returns + ------- + list + A list of dictionaries containing repos which match the given arguments + """ + if not development and not request.is_secure: return generate_upgrade_request() username = request.args.get("username") group_name = request.args.get("group_name") - page = request.args.get("page") - page_size = request.args.get("page_size") + + # Set default values for ancillary arguments + page = request.args.get("page") or 0 + page_size = request.args.get("page_size") or 25 sort = request.args.get("sort") - direction = request.args.get("direction") + direction = request.args.get("direction") or ("ASC" if sort else None) + - if (not username) or (not group_name) or (not page) or (not page_size) or (sort and not direction) or (not sort and direction): + + if (not username) or (not group_name): return jsonify({"status": "Missing argument"}), 400 if direction and direction != "ASC" and direction != "DESC": - return {"status": "Invalid direction"} + return jsonify({"status": "Invalid direction"}), 400 try: page = int(page) @@ -396,6 +423,21 @@ def group_repos(): @server.app.route(f"/{AUGUR_API_VERSION}/user/group_repo_count", methods=['GET', 'POST']) def group_repo_count(): + """Count repos from a user group by name + + Arguments + ---------- + username : str + The username of the user making the request + group_name : str + The name of the group to select + + Returns + ------- + int + A count of the repos in the given user group + """ + if not development and not request.is_secure: return generate_upgrade_request() @@ -434,6 +476,19 @@ def group_repo_count(): @server.app.route(f"/{AUGUR_API_VERSION}/user/groups", methods=['GET', 'POST']) def get_user_groups(): + """Get a list of user groups by username + + Arguments + ---------- + username : str + The username of the user making the request + + Returns + ------- + list + A list of group names associated with the given username + """ + if not development and not request.is_secure: return generate_upgrade_request() From 87da819e1d99779b2024c7a66cd936e9b2b5b092 Mon Sep 17 00:00:00 2001 From: Isaac Milarsky Date: Wed, 4 Jan 2023 14:17:19 -0600 Subject: [PATCH 029/150] Change to rabbitmq broker Signed-off-by: Isaac Milarsky --- augur/application/cli/config.py | 6 +++++- augur/application/config.py | 3 +++ augur/tasks/init/__init__.py | 10 ++++++++++ augur/tasks/init/celery_app.py | 2 +- scripts/install/config.sh | 23 +++++++++++++++++++++-- 5 files changed, 40 insertions(+), 4 deletions(-) diff --git a/augur/application/cli/config.py b/augur/application/cli/config.py index dca75ad115..3d93d363d7 100644 --- a/augur/application/cli/config.py +++ b/augur/application/cli/config.py @@ -27,9 +27,10 @@ def cli(): @click.option('--facade-repo-directory', help="Directory on the database server where Facade should clone repos", envvar=ENVVAR_PREFIX + 'FACADE_REPO_DIRECTORY') @click.option('--gitlab-api-key', help="GitLab API key for data collection from the GitLab API", envvar=ENVVAR_PREFIX + 'GITLAB_API_KEY') @click.option('--redis-conn-string', help="String to connect to redis cache", envvar=ENVVAR_PREFIX + 'REDIS_CONN_STRING') +@click.option('--rabbitmq-conn-string', help="String to connect to rabbitmq broker", envvar=ENVVAR_PREFIX + 'RABBITMQ_CONN_STRING') @test_connection @test_db_connection -def init_config(github_api_key, facade_repo_directory, gitlab_api_key, redis_conn_string): +def init_config(github_api_key, facade_repo_directory, gitlab_api_key, redis_conn_string, rabbitmq_conn_string): if not github_api_key: @@ -89,6 +90,9 @@ def init_config(github_api_key, facade_repo_directory, gitlab_api_key, redis_con default_config["Redis"]["connection_string"] = redis_conn_string + if rabbitmq_conn_string: + default_config["RabbitMQ"]["connection_string"] = rabbitmq_conn_string + default_config["Keys"] = keys default_config["Facade"]["repo_directory"] = facade_repo_directory diff --git a/augur/application/config.py b/augur/application/config.py index 58c2fa3eea..ee1cdff367 100644 --- a/augur/application/config.py +++ b/augur/application/config.py @@ -71,6 +71,9 @@ def get_development_flag(): "cache_group": 0, "connection_string": "redis://127.0.0.1:6379/" }, + "RabbitMQ": { + "connection_string": "amqp://augur:password123@localhost:5672/augur_vhost" + }, "Tasks": { "collection_interval": 2592000 }, diff --git a/augur/tasks/init/__init__.py b/augur/tasks/init/__init__.py index 36486d08bb..eb590a99ab 100644 --- a/augur/tasks/init/__init__.py +++ b/augur/tasks/init/__init__.py @@ -18,3 +18,13 @@ def get_redis_conn_values(): redis_conn_string += "/" return redis_db_number, redis_conn_string + +def get_rabbitmq_conn_string(): + logger = logging.getLogger(__name__) + + with DatabaseSession(logger) as session: + config = AugurConfig(logger, session) + + rabbbitmq_conn_string = config.get_value("RabbitMQ", "connection_string") + + return rabbbitmq_conn_string diff --git a/augur/tasks/init/celery_app.py b/augur/tasks/init/celery_app.py index b4dacc9c66..ef34344ab4 100644 --- a/augur/tasks/init/celery_app.py +++ b/augur/tasks/init/celery_app.py @@ -48,7 +48,7 @@ redis_db_number, redis_conn_string = get_redis_conn_values() # initialize the celery app -BROKER_URL = f'{redis_conn_string}{redis_db_number}' +BROKER_URL = get_rabbitmq_conn_string()#f'{redis_conn_string}{redis_db_number}' BACKEND_URL = f'{redis_conn_string}{redis_db_number+1}' celery_app = Celery('tasks', broker=BROKER_URL, backend=BACKEND_URL, include=tasks) diff --git a/scripts/install/config.sh b/scripts/install/config.sh index 962c865707..10a1645cf3 100755 --- a/scripts/install/config.sh +++ b/scripts/install/config.sh @@ -87,6 +87,14 @@ function get_facade_repo_path() { [[ "${facade_repo_directory}" != */ ]] && facade_repo_directory="${facade_repo_directory}/" } +function get_rabbitmq_broker_url(){ + echo + echo "Please provide your rabbitmq broker url." + echo "** This is required for Augur to run all collection tasks. ***" + read -p "broker_url: " rabbitmq_conn_string + echo +} + function create_config(){ @@ -146,13 +154,24 @@ function create_config(){ echo "Please unset AUGUR_FACADE_REPO_DIRECTORY if you would like to be prompted for the facade repo directory" facade_repo_directory=$AUGUR_FACADE_REPO_DIRECTORY fi + + if [[ -z "${RABBITMQ_CONN_STRING}" ]] + then + get_rabbitmq_broker_url + else + echo + echo "Found RABBITMQ_CONN_STRING environment variable with value $RABBITMQ_CONN_STRING" + echo "Using it in the config" + echo "Please unset RABBITMQ_CONN_STRING if you would like to be prompted for the facade repo directory" + rabbitmq_conn_string=$RABBITMQ_CONN_STRING + fi #special case for docker entrypoint if [ $target = "docker" ]; then - cmd=( augur config init --github-api-key $github_api_key --gitlab-api-key $gitlab_api_key --facade-repo-directory $facade_repo_directory --redis-conn-string $redis_conn_string ) + cmd=( augur config init --github-api-key $github_api_key --gitlab-api-key $gitlab_api_key --facade-repo-directory $facade_repo_directory --redis-conn-string $redis_conn_string --rabbitmq-conn-string $rabbitmq_conn_string ) echo "init with redis $redis_conn_string" else - cmd=( augur config init --github-api-key $github_api_key --gitlab-api-key $gitlab_api_key --facade-repo-directory $facade_repo_directory ) + cmd=( augur config init --github-api-key $github_api_key --gitlab-api-key $gitlab_api_key --facade-repo-directory $facade_repo_directory --rabbitmq-conn-string $rabbitmq_conn_string ) fi From ff116a6b66b56b2a01fade528cfa94983e62cb67 Mon Sep 17 00:00:00 2001 From: Isaac Milarsky Date: Wed, 4 Jan 2023 14:29:23 -0600 Subject: [PATCH 030/150] syntax Signed-off-by: Isaac Milarsky --- augur/tasks/init/celery_app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/augur/tasks/init/celery_app.py b/augur/tasks/init/celery_app.py index ef34344ab4..ff58da60ce 100644 --- a/augur/tasks/init/celery_app.py +++ b/augur/tasks/init/celery_app.py @@ -12,7 +12,7 @@ from augur.application.logs import TaskLogConfig from augur.application.db.session import DatabaseSession from augur.application.db.engine import get_database_string -from augur.tasks.init import get_redis_conn_values +from augur.tasks.init import get_redis_conn_values, get_rabbitmq_conn_string logger = logging.getLogger(__name__) From 5412ae47fa0f024a4afdc556e6abb5beea34998b Mon Sep 17 00:00:00 2001 From: Andrew Brain Date: Wed, 4 Jan 2023 15:24:10 -0600 Subject: [PATCH 031/150] Add docs and a few fixes Signed-off-by: Andrew Brain --- augur/util/repo_load_controller.py | 139 ++++++++++++++++++++++------- 1 file changed, 108 insertions(+), 31 deletions(-) diff --git a/augur/util/repo_load_controller.py b/augur/util/repo_load_controller.py index 4628c40a0f..25042d465b 100644 --- a/augur/util/repo_load_controller.py +++ b/augur/util/repo_load_controller.py @@ -29,7 +29,7 @@ def __init__(self, gh_session): def is_valid_repo(self, url: str) -> bool: """Determine whether repo url is valid. - + Args: url: repo_url @@ -55,7 +55,7 @@ def is_valid_repo(self, url: str) -> bool: # if there was an error return False if "message" in result.json().keys(): return False - + return True @@ -64,7 +64,7 @@ def retrieve_org_repos(self, url: str) -> List[str]: Note: If the org url is not valid it will return [] - + Args: url: org url @@ -74,13 +74,14 @@ def retrieve_org_repos(self, url: str) -> List[str]: owner = self.parse_org_url(url) if not owner: + # TODO: Change to return empty list so it matches the docs return False url = ORG_REPOS_ENDPOINT.format(owner) - + repos = [] with GithubTaskSession(logger) as session: - + for page_data, page in GithubPaginator(url, session.oauths, logger).iter_pages(): if page_data is None: @@ -93,7 +94,16 @@ def retrieve_org_repos(self, url: str) -> List[str]: return repo_urls - def is_valid_repo_group_id(self, repo_group_id): + def is_valid_repo_group_id(self, repo_group_id: int) -> bool: + """Deterime is repo_group_id exists. + + Args: + repo_group_id: id from the repo groups table + + Returns: + True if it exists, False if it does not + """ + query = self.session.query(RepoGroup).filter(RepoGroup.repo_group_id == repo_group_id) try: @@ -137,18 +147,18 @@ def add_repo_row(self, url: str, repo_group_id: int, tool_source): return None if repo_group_id not in DEFAULT_REPO_GROUP_IDS: - # update the repo group id + # update the repo group id query = self.session.query(Repo).filter(Repo.repo_git == url) repo = execute_session_query(query, 'one') if not repo.repo_group_id == repo_group_id: repo.repo_group_id = repo_group_id - self.session.commit() - + self.session.commit() + return result[0]["repo_id"] - def add_repo_to_user_group(self, repo_id, group_id=1): + def add_repo_to_user_group(self, repo_id: int, group_id:int = 1) -> bool: """Add a repo to a user in the user_repos table. Args: @@ -163,8 +173,8 @@ def add_repo_to_user_group(self, repo_id, group_id=1): "group_id": group_id, "repo_id": repo_id } - - + + repo_user_group_unique = ["group_id", "repo_id"] return_columns = ["group_id", "repo_id"] @@ -175,7 +185,20 @@ def add_repo_to_user_group(self, repo_id, group_id=1): return data[0]["group_id"] == group_id and data[0]["repo_id"] == repo_id - def add_user_group(self, user_id, group_name): + def add_user_group(self, user_id:int, group_name:str) -> dict: + """Add a group to the user. + + Args + user_id: id of the user + group_name: name of the group being added + + Returns: + Dict with status key that indicates the success of the operation + + Note: + If group already exists the function will return that it has been added, but a duplicate group isn't added. + It simply detects that it already exists and doesn't add it. + """ if not isinstance(user_id, int) or not isinstance(group_name, str): return {"status": "Invalid input"} @@ -189,14 +212,24 @@ def add_user_group(self, user_id, group_name): result = self.session.insert_data(user_group_data, UserGroup, ["name", "user_id"], return_columns=["group_id"]) except s.exc.IntegrityError: return {"status": "Error: User id does not exist"} - + if result: return {"status": "Group created"} else: return {"status": "Error while creating group"} - def remove_user_group(self, user_id, group_name): + def remove_user_group(self, user_id: int, group_name: str) -> dict: + """ Delete a users group of repos. + + Args: + user_id: id of the user + group_name: name of the users group + + Returns: + Dict with a status key that indicates the result of the operation + + """ # convert group_name to group_id group_id = self.convert_group_name_to_id(user_id, group_name) @@ -217,7 +250,17 @@ def remove_user_group(self, user_id, group_name): return {"status": "Group deleted"} - def convert_group_name_to_id(self, user_id, group_name): + def convert_group_name_to_id(self, user_id: int, group_name: str) -> int: + """Convert a users group name to the database group id. + + Args: + user_id: id of the user + group_name: name of the users group + + Returns: + None on failure. The group id on success. + + """ if not isinstance(user_id, int) or not isinstance(group_name, str): return None @@ -228,29 +271,37 @@ def convert_group_name_to_id(self, user_id, group_name): return None return user_group.group_id - - def get_user_groups(self, user_id): - return self.session.query(UserGroup).filter(UserGroup.user_id == user_id).all() + def get_user_groups(self, user_id: int) -> List: + + return self.session.query(UserGroup).filter(UserGroup.user_id == user_id).all() - def get_user_group_repos(self, group_id): + def get_user_group_repos(self, group_id: int) -> List: user_repos = self.session.query(UserRepo).filter(UserRepo.group_id == group_id).all() return [user_repo.repo for user_repo in user_repos] - def add_frontend_repo(self, url: List[str], user_id: int, group_name: str, group_id=None, valid_repo=False): + def add_frontend_repo(self, url: List[str], user_id: int, group_name=None, group_id=None, valid_repo=False) -> dict: """Add list of repos to a users repos. Args: urls: list of repo urls user_id: id of user_id from users table - repo_group_id: repo_group_id to add the repo to + group_name: name of group to add repo to. + group_id: id of the group + valid_repo: boolean that indicates whether the repo has already been validated Note: - If no repo_group_id is passed the repo will be added to a default repo_group + Either the group_name or group_id can be passed not both + + Returns: + Dict that contains the key "status" and additional useful data """ + if group_name and group_id: + return {"status": "Pass only the group name or group id not both"} + if group_id is None: group_id = self.convert_group_name_to_id(user_id, group_name) @@ -271,7 +322,17 @@ def add_frontend_repo(self, url: List[str], user_id: int, group_name: str, group return {"status": "Repo Added", "repo_url": url} - def remove_frontend_repo(self, repo_id, user_id, group_name): + def remove_frontend_repo(self, repo_id:int, user_id:int, group_name:str) -> dict: + """ Remove repo from a users group. + + Args: + repo_id: id of the repo to remove + user_id: id of the user + group_name: name of group the repo is being removed from + + Returns: + Dict with a key of status that indicates the result of the operation + """ if not isinstance(repo_id, int) or not isinstance(user_id, int) or not isinstance(group_name, str): return {"status": "Invalid input params"} @@ -299,7 +360,7 @@ def add_frontend_org(self, url: List[str], user_id: int, group_name: int): return {"status": "Invalid group name"} repos = self.retrieve_org_repos(url) - + if not repos: return {"status": "Invalid org", "org_url": url} @@ -308,7 +369,7 @@ def add_frontend_org(self, url: List[str], user_id: int, group_name: int): failed_repos = [] for repo in repos: - result = self.add_frontend_repo(repo, user_id, group_name, group_id, valid_repo=True) + result = self.add_frontend_repo(repo, user_id, group_id=group_id, valid_repo=True) # keep track of all the repos that failed if result["status"] != "Repo Added": @@ -352,7 +413,7 @@ def add_cli_org(self, org_name): url = f"https://github.com/{org_name}" repos = self.retrieve_org_repos(url) - + if not repos: print( f"No organization with name {org_name} could be found") @@ -379,7 +440,7 @@ def add_cli_org(self, org_name): logger.info( f"Adding {repo_url}") self.add_cli_repo({"url": repo_url, "repo_group_id": repo_group_id}, valid_repo=True) - + return {"status": "Org added"} @@ -405,10 +466,18 @@ def get_user_repo_ids(self, user_id: int) -> List[int]: return list(all_repo_ids) - def parse_repo_url(self, url): + def parse_repo_url(self, url: str) -> tuple: + """ Gets the owner and repo from a url. + + Args: + url: Github url + + Returns: + Tuple of owner and repo. Or a tuple of None and None if the url is invalid. + """ if url.endswith(".github") or url.endswith(".github.io") or url.endswith(".js"): - + result = re.search(r"https?:\/\/github\.com\/([A-Za-z0-9 \- _]+)\/([A-Za-z0-9 \- _ \.]+)(.git)?\/?$", url) else: @@ -419,13 +488,21 @@ def parse_repo_url(self, url): capturing_groups = result.groups() - + owner = capturing_groups[0] repo = capturing_groups[1] return owner, repo def parse_org_url(self, url): + """ Gets the owner from a org url. + + Args: + url: Github org url + + Returns: + Org name. Or None if the url is invalid. + """ result = re.search(r"https?:\/\/github\.com\/([A-Za-z0-9 \- _]+)\/?$", url) From c5db0cf02d8585d9ec5a033a2928ab898fdc5128 Mon Sep 17 00:00:00 2001 From: Isaac Milarsky Date: Wed, 4 Jan 2023 15:30:39 -0600 Subject: [PATCH 032/150] don't ignore result Signed-off-by: Isaac Milarsky --- augur/tasks/init/celery_app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/augur/tasks/init/celery_app.py b/augur/tasks/init/celery_app.py index ff58da60ce..457c913184 100644 --- a/augur/tasks/init/celery_app.py +++ b/augur/tasks/init/celery_app.py @@ -62,7 +62,7 @@ celery_app.conf.task_track_started = True #ignore task results by default -celery_app.conf.task_ignore_result = True +##celery_app.conf.task_ignore_result = True # store task erros even if the task result is ignored celery_app.conf.task_store_errors_even_if_ignored = True From af250fa2302a1f804822e1d4fd8aa31712a88b27 Mon Sep 17 00:00:00 2001 From: Isaac Milarsky Date: Wed, 4 Jan 2023 15:45:44 -0600 Subject: [PATCH 033/150] More logging in detect_github_repo_move Signed-off-by: Isaac Milarsky --- augur/tasks/github/detect_move/tasks.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/augur/tasks/github/detect_move/tasks.py b/augur/tasks/github/detect_move/tasks.py index 9277a90f02..2acc440747 100644 --- a/augur/tasks/github/detect_move/tasks.py +++ b/augur/tasks/github/detect_move/tasks.py @@ -9,10 +9,12 @@ def detect_github_repo_move(repo_git_identifiers : str) -> None: logger = logging.getLogger(detect_github_repo_move.__name__) + logger.info(f"Starting repo_move operation with {repo_git_identifiers}") with GithubTaskSession(logger) as session: #Ping each repo with the given repo_git to make sure #that they are still in place. for repo_git in repo_git_identifiers: query = session.query(Repo).filter(Repo.repo_git == repo_git) repo = execute_session_query(query, 'one') + logger.info(f"Pinging repo: {repo_git}") ping_github_for_repo_move(session, repo) \ No newline at end of file From 7efce307b0afbd2e73efb53658db4e1f8f73282e Mon Sep 17 00:00:00 2001 From: Isaac Milarsky Date: Wed, 4 Jan 2023 16:03:03 -0600 Subject: [PATCH 034/150] debug Signed-off-by: Isaac Milarsky --- augur/tasks/util/worker_util.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/augur/tasks/util/worker_util.py b/augur/tasks/util/worker_util.py index 960ff9cc47..d6b3af6920 100644 --- a/augur/tasks/util/worker_util.py +++ b/augur/tasks/util/worker_util.py @@ -16,8 +16,10 @@ def create_grouped_task_load(*args,processes=8,dataList=[],task=None): if not dataList or not task: raise AssertionError + print(f"Splitting {len(dataList)} items") numpyData = np.array(list(dataList)) listsSplitForProcesses = np.array_split(numpyData, processes) + print("Done splitting items.") #print("args") #print(args) From a92df41f040e804a12ed47a00a5036b4ac7b6de6 Mon Sep 17 00:00:00 2001 From: Ulincsys <28362836a@gmail.com> Date: Thu, 5 Jan 2023 03:03:34 -0600 Subject: [PATCH 035/150] More oauth work --- augur/api/routes/user.py | 95 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 94 insertions(+), 1 deletion(-) diff --git a/augur/api/routes/user.py b/augur/api/routes/user.py index 77987168e5..973a09cbdf 100644 --- a/augur/api/routes/user.py +++ b/augur/api/routes/user.py @@ -27,6 +27,57 @@ AUGUR_API_VERSION = 'api/unstable' +""" + Extract Bearer token from request header, + using the standard oauth2 format +""" +def get_bearer_token(request): + token = request.headers.get("Authorization") + + if token and " " in token: + token = token.split(" ") + if len(token) == 2: + return token[1] + + for substr in token: + if substr and "Bearer" not in substr: + return substr + + return token + +def user_login_required(fun): + def wrapper(*args, **kwargs): + # TODO check that user session token is valid + + # We still need to decide on the format for this + + # If valid: + return fun(*args, **kwargs) + + # else: return error JSON + + return wrapper + +def api_key_required(fun): + def wrapper(*args, **kwargs): + # TODO check that API key is valid + + # If valid: + return fun(*args, **kwargs) + + # else: return error JSON + + return wrapper + +# usage: +""" +@app.route("/path") +@api_key_required +@user_login_required +def priviledged_function(): + stuff +""" + # TODO This should probably be available to all endpoints def generate_upgrade_request(): # https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/426 @@ -60,7 +111,49 @@ def validate_user(): return jsonify({"status": "Invalid username"}) if checkPassword == False: return jsonify({"status": "Invalid password"}) - return jsonify({"status": "Validated"}) + + # TODO Generate user session token to be stored in client browser + + token = "USER SESSION TOKEN" + + return jsonify({"status": "Validated", "session": token}) + + @server.app.route(f"/{AUGUR_API_VERSION}/user/oauth", methods=['POST']) + def oauth_validate(): + # Check if user has an active session + current_session = request.args.get("session") + + if current_session: + # TODO validate session token + # If invalid, set current_session to None to force validation + pass + + if not current_session: + return jsonify({"status": "Invalid session"}) + + # TODO generate oauth token and store in temporary table + # Ideally should be valid for ~1 minute + # oauth entry: (token: str, generated: date) + + token = "TEMPORARY VALUE" + + return jsonify({"status": "Validated", "oauth_token": token}) + + @server.app.route(f"/{AUGUR_API_VERSION}/user/generate_session", methods=['POST']) + def generate_session(): + # TODO Validate oauth token + oauth = request.args.get("oauth_token") + + # If invalid, return error JSON: + # return jsonify({"status": "Invalid oauth token"}) + + # If valid, pop oauth token from temporary table + # Generate user session token to be stored in client browser + + token = "USER SESSION TOKEN" + user = "USERNAME" + + return jsonify({"status": "Validated", "username": user, "session": token}) @server.app.route(f"/{AUGUR_API_VERSION}/user/query", methods=['POST']) def query_user(): From d55e8b261ad2af163564da4c4d3d46c711bb9443 Mon Sep 17 00:00:00 2001 From: Isaac Milarsky Date: Thu, 5 Jan 2023 12:20:21 -0600 Subject: [PATCH 036/150] print Signed-off-by: Isaac Milarsky --- augur/tasks/util/worker_util.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/augur/tasks/util/worker_util.py b/augur/tasks/util/worker_util.py index d6b3af6920..8efac1db7b 100644 --- a/augur/tasks/util/worker_util.py +++ b/augur/tasks/util/worker_util.py @@ -17,8 +17,8 @@ def create_grouped_task_load(*args,processes=8,dataList=[],task=None): raise AssertionError print(f"Splitting {len(dataList)} items") - numpyData = np.array(list(dataList)) - listsSplitForProcesses = np.array_split(numpyData, processes) + #numpyData = np.array(list(dataList)) + listsSplitForProcesses = np.array_split(list(dataList), processes) print("Done splitting items.") #print("args") From 4cbc2108eba9b31814aeaeb6e50ebe8cae36ef83 Mon Sep 17 00:00:00 2001 From: Andrew Brain Date: Thu, 5 Jan 2023 12:21:54 -0600 Subject: [PATCH 037/150] Add auth to endpoints Signed-off-by: Andrew Brain --- augur/api/routes/user.py | 159 +++++++-------- augur/application/db/models/__init__.py | 4 +- .../application/db/models/augur_operations.py | 30 +++ .../versions/2_added_user_groups_and_login.py | 188 ++++++++++++++++++ augur/util/repo_load_controller.py | 2 +- 5 files changed, 296 insertions(+), 87 deletions(-) create mode 100644 augur/application/schema/alembic/versions/2_added_user_groups_and_login.py diff --git a/augur/api/routes/user.py b/augur/api/routes/user.py index 973a09cbdf..e14cd8780d 100644 --- a/augur/api/routes/user.py +++ b/augur/api/routes/user.py @@ -13,12 +13,13 @@ from werkzeug.security import generate_password_hash, check_password_hash from sqlalchemy.sql import text from sqlalchemy.orm import sessionmaker +from sqlalchemy.orm.exc import NoResultFound from augur.application.db.session import DatabaseSession from augur.tasks.github.util.github_task_session import GithubTaskSession from augur.util.repo_load_controller import RepoLoadController -from augur.application.db.models import User, UserRepo, UserGroup +from augur.application.db.models import User, UserRepo, UserGroup, UserSessionToken, ClientToken from augur.application.config import get_development_flag logger = logging.getLogger(__name__) development = get_development_flag() @@ -51,22 +52,46 @@ def wrapper(*args, **kwargs): # We still need to decide on the format for this + user_token = request.args.get("user_token") + print(user_token) + # If valid: - return fun(*args, **kwargs) + if user_token: + + session = Session() + try: + user = session.query(UserSessionToken).filter(UserSessionToken.token == user_token).one().user + + return fun(user=user, *args, **kwargs) + except NoResultFound: + print("Not found") # else: return error JSON - + return {"status": "Invalid user session"} + + wrapper.__name__ = fun.__name__ return wrapper def api_key_required(fun): def wrapper(*args, **kwargs): # TODO check that API key is valid + client_token = request.args.get("client_api_key") + # If valid: - return fun(*args, **kwargs) + if client_token: + + session = Session() + try: + session.query(ClientToken).filter(ClientToken.token == client_token).one() + return fun(*args, **kwargs) + except NoResultFound: + pass # else: return error JSON + return {"status": "Unauthorized client"} + wrapper.__name__ = fun.__name__ return wrapper # usage: @@ -156,7 +181,9 @@ def generate_session(): return jsonify({"status": "Validated", "username": user, "session": token}) @server.app.route(f"/{AUGUR_API_VERSION}/user/query", methods=['POST']) - def query_user(): + @api_key_required + @user_login_required + def query_user(user): if not development and not request.is_secure: return generate_upgrade_request() @@ -203,19 +230,13 @@ def create_user(): return jsonify(msg='Error: {}. '.format(exception_message)), 400 @server.app.route(f"/{AUGUR_API_VERSION}/user/remove", methods=['POST', 'DELETE']) - def delete_user(): + @api_key_required + @user_login_required + def delete_user(user): if not development and not request.is_secure: return generate_upgrade_request() session = Session() - username = request.args.get("username") - if username is None: - return jsonify({"status": "Missing argument"}), 400 - - user = session.query(User).filter(User.login_name == username).first() - - if user is None: - return jsonify({"status": "User does not exist"}) for group in user.groups: user_repos_list = group.repos @@ -230,28 +251,17 @@ def delete_user(): return jsonify({"status": "User deleted"}), 200 @server.app.route(f"/{AUGUR_API_VERSION}/user/update", methods=['POST']) - def update_user(): + @api_key_required + @user_login_required + def update_user(user): if not development and not request.is_secure: return generate_upgrade_request() session = Session() - username = request.args.get("username") - password = request.args.get("password") email = request.args.get("email") new_login_name = request.args.get("new_username") new_password = request.args.get("new_password") - if username is None or password is None: - return jsonify({"status": "Missing argument"}), 400 - - user = session.query(User).filter(User.login_name == username).first() - if user is None: - return jsonify({"status": "User does not exist"}) - - checkPassword = check_password_hash(user.login_hashword, password) - if checkPassword == False: - return jsonify({"status": "Invalid password"}) - if email is not None: existing_user = session.query(User).filter(User.email == email).one() if existing_user is not None: @@ -279,22 +289,19 @@ def update_user(): @server.app.route(f"/{AUGUR_API_VERSION}/user/add_repo", methods=['GET', 'POST']) - def add_user_repo(): + @api_key_required + @user_login_required + def add_user_repo(user): if not development and not request.is_secure: return generate_upgrade_request() - username = request.args.get("username") repo = request.args.get("repo_url") group_name = request.args.get("group_name") with GithubTaskSession(logger) as session: - if username is None or repo is None or group_name is None: + if repo is None or group_name is None: return jsonify({"status": "Missing argument"}), 400 - user = session.query(User).filter( - User.login_name == username).first() - if user is None: - return jsonify({"status": "User does not exist"}) repo_load_controller = RepoLoadController(gh_session=session) @@ -304,11 +311,12 @@ def add_user_repo(): @server.app.route(f"/{AUGUR_API_VERSION}/user/add_group", methods=['GET', 'POST']) - def add_user_group(): + @api_key_required + @user_login_required + def add_user_group(user): if not development and not request.is_secure: return generate_upgrade_request() - username = request.args.get("username") group_name = request.args.get("group_name") if group_name == "default": @@ -316,13 +324,9 @@ def add_user_group(): with GithubTaskSession(logger) as session: - if username is None or group_name is None: + if group_name is None: return jsonify({"status": "Missing argument"}), 400 - user = session.query(User).filter(User.login_name == username).first() - if user is None: - return jsonify({"status": "User does not exist"}) - repo_load_controller = RepoLoadController(gh_session=session) result = repo_load_controller.add_user_group(user.user_id, group_name) @@ -330,22 +334,19 @@ def add_user_group(): return jsonify(result) @server.app.route(f"/{AUGUR_API_VERSION}/user/remove_group", methods=['GET', 'POST']) - def remove_user_group(): + @api_key_required + @user_login_required + def remove_user_group(user): if not development and not request.is_secure: return generate_upgrade_request() - username = request.args.get("username") group_name = request.args.get("group_name") with GithubTaskSession(logger) as session: - if username is None or group_name is None: + if group_name is None: return jsonify({"status": "Missing argument"}), 400 - user = session.query(User).filter(User.login_name == username).first() - if user is None: - return jsonify({"status": "User does not exist"}) - repo_load_controller = RepoLoadController(gh_session=session) result = repo_load_controller.remove_user_group(user.user_id, group_name) @@ -356,24 +357,20 @@ def remove_user_group(): @server.app.route(f"/{AUGUR_API_VERSION}/user/add_org", methods=['GET', 'POST']) - def add_user_org(): + @api_key_required + @user_login_required + def add_user_org(user): if not development and not request.is_secure: return generate_upgrade_request() - username = request.args.get("username") org = request.args.get("org_url") group_name = request.args.get("group_name") with GithubTaskSession(logger) as session: - if username is None or org is None or group_name is None: + if org is None or group_name is None: return jsonify({"status": "Missing argument"}), 400 - user = session.query(User).filter( - User.login_name == username).first() - if user is None: - return jsonify({"status": "User does not exist"}) - repo_load_controller = RepoLoadController(gh_session=session) result = repo_load_controller.add_frontend_org(org, user.user_id, group_name) @@ -382,22 +379,23 @@ def add_user_org(): @server.app.route(f"/{AUGUR_API_VERSION}/user/remove_repo", methods=['GET', 'POST']) - def remove_user_repo(): + @api_key_required + @user_login_required + def remove_user_repo(user): if not development and not request.is_secure: return generate_upgrade_request() + try: + repo_id = int(request.args.get("repo_id")) + except ValueError: + return {"status": "repo_id must be an integer"} - username = request.args.get("username") - repo_id = request.args.get("repo_id") group_name = request.args.get("group_name") + with GithubTaskSession(logger) as session: - if username is None or repo_id is None or group_name is None: + if repo_id is None or group_name is None: return jsonify({"status": "Missing argument"}), 400 - user = session.query(User).filter( - User.login_name == username).first() - if user is None: - return jsonify({"status": "User does not exist"}) repo_load_controller = RepoLoadController(gh_session=session) @@ -408,7 +406,9 @@ def remove_user_repo(): @server.app.route(f"/{AUGUR_API_VERSION}/user/group_repos", methods=['GET', 'POST']) - def group_repos(): + @api_key_required + @user_login_required + def group_repos(user): """Select repos from a user group by name Arguments @@ -435,7 +435,6 @@ def group_repos(): if not development and not request.is_secure: return generate_upgrade_request() - username = request.args.get("username") group_name = request.args.get("group_name") # Set default values for ancillary arguments @@ -444,9 +443,7 @@ def group_repos(): sort = request.args.get("sort") direction = request.args.get("direction") or ("ASC" if sort else None) - - - if (not username) or (not group_name): + if not group_name: return jsonify({"status": "Missing argument"}), 400 if direction and direction != "ASC" and direction != "DESC": @@ -465,8 +462,6 @@ def group_repos(): with DatabaseSession(logger) as session: controller = RepoLoadController(session) - - user = session.query(User).filter(User.login_name == username).first() group_id = controller.convert_group_name_to_id(user.user_id, group_name) if group_id is None: @@ -515,7 +510,9 @@ def group_repos(): @server.app.route(f"/{AUGUR_API_VERSION}/user/group_repo_count", methods=['GET', 'POST']) - def group_repo_count(): + @api_key_required + @user_login_required + def group_repo_count(user): """Count repos from a user group by name Arguments @@ -534,17 +531,14 @@ def group_repo_count(): if not development and not request.is_secure: return generate_upgrade_request() - username = request.args.get("username") group_name = request.args.get("group_name") - if (not username) or (not group_name): + if not group_name: return jsonify({"status": "Missing argument"}), 400 with DatabaseSession(logger) as session: controller = RepoLoadController(session) - - user = session.query(User).filter(User.login_name == username).first() group_id = controller.convert_group_name_to_id(user.user_id, group_name) if group_id is None: @@ -568,7 +562,9 @@ def group_repo_count(): @server.app.route(f"/{AUGUR_API_VERSION}/user/groups", methods=['GET', 'POST']) - def get_user_groups(): + @api_key_required + @user_login_required + def get_user_groups(user): """Get a list of user groups by username Arguments @@ -585,16 +581,9 @@ def get_user_groups(): if not development and not request.is_secure: return generate_upgrade_request() - username = request.args.get("username") - - if not username: - return jsonify({"status": "Missing argument"}), 400 - with DatabaseSession(logger) as session: controller = RepoLoadController(session) - - user = session.query(User).filter(User.login_name == username).first() user_groups = controller.get_user_groups(user.user_id) diff --git a/augur/application/db/models/__init__.py b/augur/application/db/models/__init__.py index f51e768ed9..5606168f64 100644 --- a/augur/application/db/models/__init__.py +++ b/augur/application/db/models/__init__.py @@ -100,5 +100,7 @@ Config, User, UserRepo, - UserGroup + UserGroup, + UserSessionToken, + ClientToken ) diff --git a/augur/application/db/models/augur_operations.py b/augur/application/db/models/augur_operations.py index 782bdb11bc..4ddb30b5fb 100644 --- a/augur/application/db/models/augur_operations.py +++ b/augur/application/db/models/augur_operations.py @@ -193,6 +193,7 @@ class User(Base): ) groups = relationship("UserGroup") + tokens = relationship("UserSessionToken") class UserGroup(Base): @@ -230,3 +231,32 @@ class UserRepo(Base): repo = relationship("Repo") group = relationship("UserGroup") +class UserSessionToken(Base): + __tablename__ = "user_session_tokens" + __table_args__ = ( + { + "schema": "augur_operations" + } + ) + + token = Column(String, primary_key=True, nullable=False) + user_id = Column(ForeignKey("augur_operations.users.user_id", name="user_session_token_user_id_fkey")) + expiration = Column(BigInteger) + + user = relationship("User") + + +class ClientToken(Base): + __tablename__ = "client_tokens" + __table_args__ = ( + { + "schema": "augur_operations" + } + ) + + token = Column(String, primary_key=True, nullable=False) + name = Column(String, nullable=False) + expiration = Column(BigInteger) + + + diff --git a/augur/application/schema/alembic/versions/2_added_user_groups_and_login.py b/augur/application/schema/alembic/versions/2_added_user_groups_and_login.py new file mode 100644 index 0000000000..94abb429cc --- /dev/null +++ b/augur/application/schema/alembic/versions/2_added_user_groups_and_login.py @@ -0,0 +1,188 @@ +"""Added user groups and login keys + +Revision ID: 2 +Revises: 1 +Create Date: 2022-12-19 11:00:37.509132 + +""" +import logging + +from alembic import op +import sqlalchemy as sa +from augur.application.db.session import DatabaseSession +from augur.application.db.models.augur_operations import UserGroup, UserRepo + +CLI_USER_ID = 1 + + +# revision identifiers, used by Alembic. +revision = '2' +down_revision = '1' +branch_labels = None +depends_on = None + +logger = logging.getLogger(__name__) + +def upgrade(): + + with DatabaseSession(logger) as session: + + create_user_groups_table = """ + CREATE TABLE "augur_operations"."user_groups" ( + "group_id" BIGSERIAL NOT NULL, + "user_id" int4 NOT NULL, + "name" varchar COLLATE "pg_catalog"."default" NOT NULL, + PRIMARY KEY ("group_id"), + FOREIGN KEY ("user_id") REFERENCES "augur_operations"."users" ("user_id") ON DELETE NO ACTION ON UPDATE NO ACTION, + UNIQUE ("user_id", "name") + ); + + + ALTER TABLE "augur_operations"."user_groups" + OWNER TO "augur"; + + INSERT INTO "augur_operations"."user_groups" ("group_id", "user_id", "name") VALUES (1, {}, 'default') ON CONFLICT ("user_id", "name") DO NOTHING; + ALTER SEQUENCE user_groups_group_id_seq RESTART WITH 2; + """.format(CLI_USER_ID) + + session.execute_sql(sa.sql.text(create_user_groups_table)) + + + user_repos = [] + + # create user group for all the users that have repos + user_id_query = sa.sql.text("""SELECT DISTINCT(user_id) FROM user_repos;""") + user_groups = session.fetchall_data_from_sql_text(user_id_query) + if user_groups: + + result = [] + for group in user_groups: + + user_id = group["user_id"] + + if user_id == CLI_USER_ID: + continue + + user_group_insert = sa.sql.text(f"""INSERT INTO "augur_operations"."user_groups" ("user_id", "name") VALUES ({user_id}, 'default') RETURNING group_id, user_id;""") + result.append(session.fetchall_data_from_sql_text(user_group_insert)[0]) + + # cli user mapping by default + user_group_id_mapping = {CLI_USER_ID: "1"} + for row in result: + user_group_id_mapping[row["user_id"]] = row["group_id"] + + + user_repo_query = sa.sql.text("""SELECT * FROM user_repos;""") + user_repo_data = session.fetchall_data_from_sql_text(user_repo_query) + for row in user_repo_data: + row.update({"group_id": user_group_id_mapping[row["user_id"]]}) + del row["user_id"] + user_repos.extend(user_repo_data) + + # remove data from table before modifiying it + remove_data_from_user_repos_query = sa.sql.text("""DELETE FROM user_repos;""") + session.execute_sql(remove_data_from_user_repos_query) + + + table_changes = """ + ALTER TABLE user_repos + ADD COLUMN group_id BIGINT, + ADD CONSTRAINT user_repos_group_id_fkey FOREIGN KEY (group_id) REFERENCES user_groups(group_id), + DROP COLUMN user_id, + ADD PRIMARY KEY (group_id, repo_id); + """ + + session.execute_sql(sa.sql.text(table_changes)) + + for data in user_repos: + + group_id = data["group_id"] + repo_id = data["repo_id"] + + user_repo_insert = sa.sql.text(f"""INSERT INTO "augur_operations"."user_repos" ("group_id", "repo_id") VALUES ({group_id}, {repo_id});""") + result = session.execute_sql(user_repo_insert) + + op.create_table('client_tokens', + sa.Column('name', sa.String(), nullable=False), + sa.Column('token', sa.String(), nullable=False), + sa.Column('expiration', sa.BigInteger(), nullable=True), + sa.PrimaryKeyConstraint('token'), + schema='augur_operations' + ) + op.create_table('user_session_tokens', + sa.Column('token', sa.String(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('expiration', sa.BigInteger(), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['augur_operations.users.user_id'], name='user_session_token_user_fk'), + sa.PrimaryKeyConstraint('token'), + schema='augur_operations' + ) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + + + user_group_ids = {} + group_repo_ids = {} + with DatabaseSession(logger) as session: + user_id_query = sa.sql.text("""SELECT * FROM user_groups;""") + user_groups = session.fetchall_data_from_sql_text(user_id_query) + for row in user_groups: + try: + user_group_ids[row["user_id"]].append(row["group_id"]) + except KeyError: + user_group_ids[row["user_id"]] = [row["group_id"]] + + + group_id_query = sa.sql.text("""SELECT * FROM user_repos;""") + group_repo_id_result = session.fetchall_data_from_sql_text(group_id_query) + for row in group_repo_id_result: + try: + group_repo_ids[row["group_id"]].append(row["repo_id"]) + except KeyError: + group_repo_ids[row["group_id"]] = [row["repo_id"]] + + remove_data_from_user_repos_query = sa.sql.text("""DELETE FROM user_repos;""") + session.execute_sql(remove_data_from_user_repos_query) + + + table_changes = """ + ALTER TABLE user_repos + ADD COLUMN user_id INT, + ADD CONSTRAINT user_repos_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(user_id), + DROP COLUMN group_id, + ADD PRIMARY KEY (user_id, repo_id); + DROP TABLE user_groups; + """ + + session.execute_sql(sa.sql.text(table_changes)) + + for user_id, group_ids in user_group_ids.items(): + + repos = [] + for group_id in group_ids: + try: + repos.extend(group_repo_ids[group_id]) + except KeyError: + continue + + query_text_array = ["""INSERT INTO "augur_operations"."user_repos" ("repo_id", "user_id") VALUES """] + for i, repo_id in enumerate(repos): + query_text_array.append(f"({repo_id}, {user_id})") + + delimiter = ";" if i == len(repos) -1 else "," + + query_text_array.append(delimiter) + + + query_text = "".join(query_text_array) + + session.execute_sql(sa.sql.text(query_text)) + + op.drop_table('user_session_tokens', schema='augur_operations') + op.drop_table('client_tokens', schema='augur_operations') + + # ### end Alembic commands ### diff --git a/augur/util/repo_load_controller.py b/augur/util/repo_load_controller.py index 25042d465b..e96ec0676e 100644 --- a/augur/util/repo_load_controller.py +++ b/augur/util/repo_load_controller.py @@ -335,7 +335,7 @@ def remove_frontend_repo(self, repo_id:int, user_id:int, group_name:str) -> dict """ if not isinstance(repo_id, int) or not isinstance(user_id, int) or not isinstance(group_name, str): - return {"status": "Invalid input params"} + return {"status": "Invalid types"} group_id = self.convert_group_name_to_id(user_id, group_name) if group_id is None: From 4d72ffc7d5897d028054b87e261b4f016411c34e Mon Sep 17 00:00:00 2001 From: Isaac Milarsky Date: Thu, 5 Jan 2023 13:02:34 -0600 Subject: [PATCH 038/150] re-add facade contributors to task queue Signed-off-by: Isaac Milarsky --- augur/tasks/git/facade_tasks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/augur/tasks/git/facade_tasks.py b/augur/tasks/git/facade_tasks.py index 16d111f12b..fbe4783b01 100644 --- a/augur/tasks/git/facade_tasks.py +++ b/augur/tasks/git/facade_tasks.py @@ -377,7 +377,7 @@ def generate_facade_chain(logger): facade_sequence.extend(generate_analysis_sequence(logger)) #Generate contributor analysis task group. - #facade_sequence.append(generate_contributor_sequence(logger)) + facade_sequence.append(generate_contributor_sequence(logger)) if nuke_stored_affiliations: facade_sequence.append(nuke_affiliations_facade_task.si().on_error(facade_error_handler.s()))#nuke_affiliations(session.cfg) From cf8b2466ba1c3b263fc75f626110a52ae3358f8b Mon Sep 17 00:00:00 2001 From: Isaac Milarsky Date: Thu, 5 Jan 2023 14:02:46 -0600 Subject: [PATCH 039/150] better handling and logging files model Signed-off-by: Isaac Milarsky --- augur/tasks/github/util/gh_graphql_entities.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/augur/tasks/github/util/gh_graphql_entities.py b/augur/tasks/github/util/gh_graphql_entities.py index 3323bcf3be..137bac06d3 100644 --- a/augur/tasks/github/util/gh_graphql_entities.py +++ b/augur/tasks/github/util/gh_graphql_entities.py @@ -341,8 +341,8 @@ def __iter__(self): self.logger.error( ''.join(traceback.format_exception(None, e, e.__traceback__))) - data = self.request_graphql_dict(variables=params) - coreData = self.extract_paginate_result(data) + self.logger.info(f"Graphql paramters: {params}") + return if int(coreData['totalCount']) == 0: From 22d10bd1942a0b3a8e6be978d98d4bef445c0b4c Mon Sep 17 00:00:00 2001 From: Andrew Brain Date: Thu, 5 Jan 2023 14:58:40 -0600 Subject: [PATCH 040/150] Remove unneeded file Signed-off-by: Andrew Brain --- .../versions/2_added_user_group_table.py | 168 ------------------ 1 file changed, 168 deletions(-) delete mode 100644 augur/application/schema/alembic/versions/2_added_user_group_table.py diff --git a/augur/application/schema/alembic/versions/2_added_user_group_table.py b/augur/application/schema/alembic/versions/2_added_user_group_table.py deleted file mode 100644 index 6dbc7c8551..0000000000 --- a/augur/application/schema/alembic/versions/2_added_user_group_table.py +++ /dev/null @@ -1,168 +0,0 @@ -"""Added user group table - -Revision ID: 2 -Revises: 1 -Create Date: 2022-12-19 11:00:37.509132 - -""" -import logging - -from alembic import op -import sqlalchemy as sa -from augur.application.db.session import DatabaseSession -from augur.application.db.models.augur_operations import UserGroup, UserRepo - -CLI_USER_ID = 1 - - -# revision identifiers, used by Alembic. -revision = '2' -down_revision = '1' -branch_labels = None -depends_on = None - -logger = logging.getLogger(__name__) - -def upgrade(): - - with DatabaseSession(logger) as session: - - create_user_groups_table = """ - CREATE TABLE "augur_operations"."user_groups" ( - "group_id" BIGSERIAL NOT NULL, - "user_id" int4 NOT NULL, - "name" varchar COLLATE "pg_catalog"."default" NOT NULL, - PRIMARY KEY ("group_id"), - FOREIGN KEY ("user_id") REFERENCES "augur_operations"."users" ("user_id") ON DELETE NO ACTION ON UPDATE NO ACTION, - UNIQUE ("user_id", "name") - ); - - - ALTER TABLE "augur_operations"."user_groups" - OWNER TO "augur"; - - INSERT INTO "augur_operations"."user_groups" ("group_id", "user_id", "name") VALUES (1, {}, 'default') ON CONFLICT ("user_id", "name") DO NOTHING; - ALTER SEQUENCE user_groups_group_id_seq RESTART WITH 2; - """.format(CLI_USER_ID) - - session.execute_sql(sa.sql.text(create_user_groups_table)) - - - user_repos = [] - - # create user group for all the users that have repos - user_id_query = sa.sql.text("""SELECT DISTINCT(user_id) FROM user_repos;""") - user_groups = session.fetchall_data_from_sql_text(user_id_query) - if user_groups: - - result = [] - for group in user_groups: - - user_id = group["user_id"] - - if user_id == CLI_USER_ID: - continue - - user_group_insert = sa.sql.text(f"""INSERT INTO "augur_operations"."user_groups" ("user_id", "name") VALUES ({user_id}, 'default') RETURNING group_id, user_id;""") - result.append(session.fetchall_data_from_sql_text(user_group_insert)[0]) - - # cli user mapping by default - user_group_id_mapping = {CLI_USER_ID: "1"} - for row in result: - user_group_id_mapping[row["user_id"]] = row["group_id"] - - - user_repo_query = sa.sql.text("""SELECT * FROM user_repos;""") - user_repo_data = session.fetchall_data_from_sql_text(user_repo_query) - for row in user_repo_data: - row.update({"group_id": user_group_id_mapping[row["user_id"]]}) - del row["user_id"] - user_repos.extend(user_repo_data) - - # remove data from table before modifiying it - remove_data_from_user_repos_query = sa.sql.text("""DELETE FROM user_repos;""") - session.execute_sql(remove_data_from_user_repos_query) - - - table_changes = """ - ALTER TABLE user_repos - ADD COLUMN group_id BIGINT, - ADD CONSTRAINT user_repos_group_id_fkey FOREIGN KEY (group_id) REFERENCES user_groups(group_id), - DROP COLUMN user_id, - ADD PRIMARY KEY (group_id, repo_id); - """ - - session.execute_sql(sa.sql.text(table_changes)) - - for data in user_repos: - - group_id = data["group_id"] - repo_id = data["repo_id"] - - user_repo_insert = sa.sql.text(f"""INSERT INTO "augur_operations"."user_repos" ("group_id", "repo_id") VALUES ({group_id}, {repo_id});""") - result = session.execute_sql(user_repo_insert) - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - - - user_group_ids = {} - group_repo_ids = {} - with DatabaseSession(logger) as session: - user_id_query = sa.sql.text("""SELECT * FROM user_groups;""") - user_groups = session.fetchall_data_from_sql_text(user_id_query) - for row in user_groups: - try: - user_group_ids[row["user_id"]].append(row["group_id"]) - except KeyError: - user_group_ids[row["user_id"]] = [row["group_id"]] - - - group_id_query = sa.sql.text("""SELECT * FROM user_repos;""") - group_repo_id_result = session.fetchall_data_from_sql_text(group_id_query) - for row in group_repo_id_result: - try: - group_repo_ids[row["group_id"]].append(row["repo_id"]) - except KeyError: - group_repo_ids[row["group_id"]] = [row["repo_id"]] - - remove_data_from_user_repos_query = sa.sql.text("""DELETE FROM user_repos;""") - session.execute_sql(remove_data_from_user_repos_query) - - - table_changes = """ - ALTER TABLE user_repos - ADD COLUMN user_id INT, - ADD CONSTRAINT user_repos_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(user_id), - DROP COLUMN group_id, - ADD PRIMARY KEY (user_id, repo_id); - DROP TABLE user_groups; - """ - - session.execute_sql(sa.sql.text(table_changes)) - - for user_id, group_ids in user_group_ids.items(): - - repos = [] - for group_id in group_ids: - try: - repos.extend(group_repo_ids[group_id]) - except KeyError: - continue - - query_text_array = ["""INSERT INTO "augur_operations"."user_repos" ("repo_id", "user_id") VALUES """] - for i, repo_id in enumerate(repos): - query_text_array.append(f"({repo_id}, {user_id})") - - delimiter = ";" if i == len(repos) -1 else "," - - query_text_array.append(delimiter) - - - query_text = "".join(query_text_array) - - session.execute_sql(sa.sql.text(query_text)) - - # ### end Alembic commands ### From 9c15799dcebd2c891a5064707b4a05f7fcf7e8ca Mon Sep 17 00:00:00 2001 From: Isaac Milarsky Date: Thu, 5 Jan 2023 15:18:29 -0600 Subject: [PATCH 041/150] take advantage of rabbitmq allowing us to use celery result Signed-off-by: Isaac Milarsky --- augur/tasks/data_analysis/__init__.py | 7 +++++-- augur/tasks/start_tasks.py | 14 +++++++++++--- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/augur/tasks/data_analysis/__init__.py b/augur/tasks/data_analysis/__init__.py index 1d0877cdaf..69c2e690d8 100644 --- a/augur/tasks/data_analysis/__init__.py +++ b/augur/tasks/data_analysis/__init__.py @@ -11,6 +11,7 @@ from augur.tasks.init.celery_app import celery_app as celery import logging +@celery.task def machine_learning_phase(): logger = logging.getLogger(machine_learning_phase.__name__) @@ -40,5 +41,7 @@ def machine_learning_phase(): task_chain = chain(*ml_tasks) - #task_chain.apply_async() - return task_chain \ No newline at end of file + result = task_chain.apply_async() + with allow_join_result(): + return result.get() + #return task_chain \ No newline at end of file diff --git a/augur/tasks/start_tasks.py b/augur/tasks/start_tasks.py index c3d29c4535..54974c2bdd 100644 --- a/augur/tasks/start_tasks.py +++ b/augur/tasks/start_tasks.py @@ -35,6 +35,7 @@ #Predefine phases. For new phases edit this and the config to reflect. #The domain of tasks ran should be very explicit. +@celery.task def prelim_phase(): logger = logging.getLogger(prelim_phase.__name__) @@ -44,8 +45,12 @@ def prelim_phase(): repos = execute_session_query(query, 'all') repo_git_list = [repo.repo_git for repo in repos] - return create_grouped_task_load(dataList=repo_git_list,task=detect_github_repo_move) + result = create_grouped_task_load(dataList=repo_git_list,task=detect_github_repo_move).apply_async() + + with allow_join_result(): + return result.get() +@celery.task def repo_collect_phase(): logger = logging.getLogger(repo_collect_phase.__name__) @@ -75,7 +80,10 @@ def repo_collect_phase(): collect_releases.si() ) - return chain(repo_task_group, refresh_materialized_views.si()) + result = chain(repo_task_group, refresh_materialized_views.si()).get() + + with allow_join_result(): + return result.get() DEFINED_COLLECTION_PHASES = [prelim_phase, repo_collect_phase] @@ -130,7 +138,7 @@ def start_data_collection(self): #Add the phase to the sequence in order as a celery task. #The preliminary task creates the larger task chain - augur_collection_sequence.append(job()) + augur_collection_sequence.append(job.si()) #Link all phases in a chain and send to celery augur_collection_chain = chain(*augur_collection_sequence) From b0829d0295601cb146d865359ed86a18d9951924 Mon Sep 17 00:00:00 2001 From: Isaac Milarsky Date: Thu, 5 Jan 2023 15:28:16 -0600 Subject: [PATCH 042/150] syntax Signed-off-by: Isaac Milarsky --- augur/tasks/start_tasks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/augur/tasks/start_tasks.py b/augur/tasks/start_tasks.py index 54974c2bdd..f8c02cc95d 100644 --- a/augur/tasks/start_tasks.py +++ b/augur/tasks/start_tasks.py @@ -80,7 +80,7 @@ def repo_collect_phase(): collect_releases.si() ) - result = chain(repo_task_group, refresh_materialized_views.si()).get() + result = chain(repo_task_group, refresh_materialized_views.si()).apply_async() with allow_join_result(): return result.get() From b12908721f5a06fc8f864429c6535df2c5c935f4 Mon Sep 17 00:00:00 2001 From: Ulincsys Date: Thu, 5 Jan 2023 16:10:48 -0600 Subject: [PATCH 043/150] Initial integration and testing --- .gitignore | 2 + augur/api/server.py | 12 +- augur/api/view/.gitignore | 1 + augur/api/view/api.py | 71 +++ augur/api/view/augur_view.py | 46 ++ augur/api/view/init.py | 93 ++++ augur/api/view/routes.py | 276 +++++++++++ augur/api/view/run.sh | 11 + augur/api/view/server/Environment.py | 52 +++ augur/api/view/server/LoginException.py | 3 + augur/api/view/server/ServerThread.py | 35 ++ augur/api/view/server/User.py | 276 +++++++++++ augur/api/view/server/__init__.py | 4 + augur/api/view/url_converters.py | 27 ++ augur/api/view/utils.py | 435 ++++++++++++++++++ augur/static/css/dashboard.css | 45 ++ augur/static/css/first_time.css | 148 ++++++ augur/static/css/stylesheet.css | 424 +++++++++++++++++ .../static/favicon/android-chrome-192x192.png | Bin 0 -> 5679 bytes .../static/favicon/android-chrome-512x512.png | Bin 0 -> 12926 bytes augur/static/favicon/apple-touch-icon.png | Bin 0 -> 5236 bytes augur/static/favicon/favicon-16x16.png | Bin 0 -> 389 bytes augur/static/favicon/favicon-32x32.png | Bin 0 -> 649 bytes augur/static/favicon/favicon.ico | Bin 0 -> 15406 bytes augur/static/favicon/favicon.png | Bin 0 -> 14195 bytes augur/static/favicon/favicon_source.svg | 78 ++++ augur/static/favicon/site.webmanifest | 1 + augur/static/img/Chaoss_Logo.png | Bin 0 -> 19432 bytes augur/static/img/Chaoss_Logo_white.png | Bin 0 -> 21153 bytes augur/static/img/auggie_shrug.png | Bin 0 -> 29646 bytes augur/static/img/augur_logo.png | Bin 0 -> 35590 bytes augur/static/img/augur_logo_black.png | Bin 0 -> 42763 bytes augur/static/img/notification-icon.svg | 80 ++++ augur/static/js/range.js | 3 + augur/static/js/sleep.js | 4 + augur/static/js/textarea_resize.js | 12 + augur/templates/admin-dashboard.html | 178 +++++++ augur/templates/first-time.html | 211 +++++++++ augur/templates/groups-table.html | 33 ++ augur/templates/index.html | 67 +++ augur/templates/loading.html | 14 + augur/templates/login.html | 157 +++++++ augur/templates/navbar.html | 67 +++ augur/templates/notice.html | 6 + augur/templates/notifications.html | 79 ++++ augur/templates/repo-commits.html | 0 augur/templates/repo-info.html | 128 ++++++ augur/templates/repos-card.html | 27 ++ augur/templates/repos-table.html | 92 ++++ augur/templates/settings.html | 139 ++++++ augur/templates/status.html | 233 ++++++++++ augur/templates/toasts.html | 60 +++ setup.py | 3 +- 53 files changed, 3631 insertions(+), 2 deletions(-) create mode 100644 augur/api/view/.gitignore create mode 100644 augur/api/view/api.py create mode 100644 augur/api/view/augur_view.py create mode 100644 augur/api/view/init.py create mode 100644 augur/api/view/routes.py create mode 100755 augur/api/view/run.sh create mode 100644 augur/api/view/server/Environment.py create mode 100644 augur/api/view/server/LoginException.py create mode 100644 augur/api/view/server/ServerThread.py create mode 100644 augur/api/view/server/User.py create mode 100644 augur/api/view/server/__init__.py create mode 100644 augur/api/view/url_converters.py create mode 100644 augur/api/view/utils.py create mode 100644 augur/static/css/dashboard.css create mode 100644 augur/static/css/first_time.css create mode 100644 augur/static/css/stylesheet.css create mode 100644 augur/static/favicon/android-chrome-192x192.png create mode 100644 augur/static/favicon/android-chrome-512x512.png create mode 100644 augur/static/favicon/apple-touch-icon.png create mode 100644 augur/static/favicon/favicon-16x16.png create mode 100644 augur/static/favicon/favicon-32x32.png create mode 100644 augur/static/favicon/favicon.ico create mode 100644 augur/static/favicon/favicon.png create mode 100644 augur/static/favicon/favicon_source.svg create mode 100644 augur/static/favicon/site.webmanifest create mode 100644 augur/static/img/Chaoss_Logo.png create mode 100644 augur/static/img/Chaoss_Logo_white.png create mode 100644 augur/static/img/auggie_shrug.png create mode 100644 augur/static/img/augur_logo.png create mode 100644 augur/static/img/augur_logo_black.png create mode 100644 augur/static/img/notification-icon.svg create mode 100644 augur/static/js/range.js create mode 100644 augur/static/js/sleep.js create mode 100644 augur/static/js/textarea_resize.js create mode 100644 augur/templates/admin-dashboard.html create mode 100644 augur/templates/first-time.html create mode 100644 augur/templates/groups-table.html create mode 100644 augur/templates/index.html create mode 100644 augur/templates/loading.html create mode 100644 augur/templates/login.html create mode 100644 augur/templates/navbar.html create mode 100644 augur/templates/notice.html create mode 100644 augur/templates/notifications.html create mode 100644 augur/templates/repo-commits.html create mode 100644 augur/templates/repo-info.html create mode 100644 augur/templates/repos-card.html create mode 100644 augur/templates/repos-table.html create mode 100644 augur/templates/settings.html create mode 100644 augur/templates/status.html create mode 100644 augur/templates/toasts.html diff --git a/.gitignore b/.gitignore index 887b67269c..7ebccb6a15 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,8 @@ augur_export_env.sh .DS_Store *.config.json !docker.config.json +config.yml +reports.yml node_modules/ diff --git a/augur/api/server.py b/augur/api/server.py index b2e069e6a1..0d41dedb4f 100644 --- a/augur/api/server.py +++ b/augur/api/server.py @@ -13,6 +13,8 @@ from typing import Optional, List, Any, Tuple +from pathlib import Path + from flask import Flask, request, Response, redirect from flask_cors import CORS import pandas as pd @@ -58,7 +60,10 @@ def __init__(self): def create_app(self): """Define the flask app and configure the routes.""" - self.app = Flask(__name__) + template_dir = str(Path(__file__).parent.parent / "templates") + static_dir = str(Path(__file__).parent.parent / "static") + + self.app = Flask(__name__, template_folder=template_dir, static_folder=static_dir) self.logger.debug("Created Flask app") # defines the api version on the flask app, @@ -126,6 +131,11 @@ def create_all_routes(self): # and this line is calling that function and passing the flask app, # so that the routes in the files can be added to the flask app module.create_routes(self) + + for route_file in ["augur_view", "routes", "api"]: + module = importlib.import_module('.' + route_file, 'augur.api.view') + + module.create_routes(self) def get_route_files(self) -> List[str]: diff --git a/augur/api/view/.gitignore b/augur/api/view/.gitignore new file mode 100644 index 0000000000..ad30bfec28 --- /dev/null +++ b/augur/api/view/.gitignore @@ -0,0 +1 @@ +*.yml \ No newline at end of file diff --git a/augur/api/view/api.py b/augur/api/view/api.py new file mode 100644 index 0000000000..ba53557d9b --- /dev/null +++ b/augur/api/view/api.py @@ -0,0 +1,71 @@ +from flask import Flask, render_template, render_template_string, request, abort, jsonify, redirect, url_for, session, flash +from flask_login import current_user, login_required +from .utils import * + +def create_routes(server): + @server.app.route('/cache/file/') + @server.app.route('/cache/file/') + def cache(file=None): + if file is None: + return redirect(url_for('root', path=getSetting('caching'))) + return redirect(url_for('root', path=toCacheFilepath(file))) + + # API endpoint to clear server cache + # TODO: Add verification + @server.app.route('/cache/clear') + def clear_cache(): + try: + for f in os.listdir(getSetting('caching')): + os.remove(os.path.join(getSetting('caching'), f)) + return renderMessage("Cache Cleared", "Server cache was successfully cleared", redirect="/") + except Exception as err: + print(err) + return renderMessage("Error", "An error occurred while clearing server cache.", redirect="/", pause=5) + + # API endpoint to reload settings from disk + @server.app.route('/settings/reload') + def reload_settings(): + loadSettings() + return renderMessage("Settings Reloaded", "Server settings were successfully reloaded.", redirect="/", pause=5) + + # Request the frontend version as a JSON string + @server.app.route('/version') + def get_version(): + return jsonify(version) + + @server.app.route('/account/repos/add/') + @server.app.route('/account/repos/add') + @login_required + def av_add_user_repo(repo_url = None): + if not repo_url: + flash("Repo or org URL must not be empty") + elif current_user.try_add_url(repo_url): + flash("Successfully added repo or org") + else: + flash("Could not add repo or org") + + return redirect(url_for("user_settings")) + + """ ---------------------------------------------------------------- + """ + @server.app.route('/requests/make/') + def make_api_request(request_endpoint): + do_cache = True + if request.headers.get("nocache") or request.args.get("nocache"): + do_cache = False + + data = requestJson(request_endpoint, do_cache) + if type(data) == tuple: + return jsonify({"request_error": data[1]}), 400 + return jsonify(data) + + """ ---------------------------------------------------------------- + Locking request loop: + This route will lock the current request until the + report request completes. A json response is guaranteed. + Assumes that the requested repo exists. + """ + @server.app.route('/requests/report/wait/') + def wait_for_report_request(id): + requestReports(id) + return jsonify(report_requests[id]) diff --git a/augur/api/view/augur_view.py b/augur/api/view/augur_view.py new file mode 100644 index 0000000000..5eb5cf55df --- /dev/null +++ b/augur/api/view/augur_view.py @@ -0,0 +1,46 @@ +from flask import Flask, render_template, redirect, url_for, session, request +from flask_login import LoginManager +from .utils import * +from .url_converters import * +from .server import User + +login_manager = LoginManager() + +def create_routes(server): + login_manager.init_app(server.app) + + server.app.secret_key = getSetting("session_key") + + server.app.url_map.converters['list'] = ListConverter + server.app.url_map.converters['bool'] = BoolConverter + server.app.url_map.converters['json'] = JSONConverter + + # Code 404 response page, for pages not found + @server.app.errorhandler(404) + def page_not_found(error): + return render_template('index.html', title='404', api_url=getSetting('serving')), 404 + + @server.app.errorhandler(405) + def unsupported_method(error): + return renderMessage("405 - Method not supported", "The resource you are trying to access does not support the request method used"), 405 + + @login_manager.unauthorized_handler + def unauthorized(): + session["login_next"] = url_for(request.endpoint, **request.args) + return redirect(url_for('user_login')) + + @login_manager.user_loader + def load_user(user_id): + user = User(user_id) + + if not user.exists: + return None + + # The flask_login library sets a unique session["_id"] + # when login_user() is called successfully + if session.get("_id") is not None: + user._is_authenticated = True + user._is_active = True + + return user + diff --git a/augur/api/view/init.py b/augur/api/view/init.py new file mode 100644 index 0000000000..556bcc93e5 --- /dev/null +++ b/augur/api/view/init.py @@ -0,0 +1,93 @@ +from pathlib import Path +from .server import Environment +import logging, sqlite3, secrets, hashlib, yaml + +env = Environment() + +# load configuration files and initialize globals +configFile = Path(env.setdefault("CONFIG_LOCATION", "config.yml")) + +version = {"major": 0, "minor": 0.1, "series": "Alpha"} + +report_requests = {} +settings = {} + +def init_settings(): + global settings + settings["approot"] = "/" + settings["caching"] = "static/cache/" + settings["cache_expiry"] = 604800 + settings["serving"] = "http://augur.chaoss.io/api/unstable" + settings["pagination_offset"] = 25 + settings["reports"] = "reports.yml" + settings["session_key"] = secrets.token_hex() + settings["version"] = version + +def write_settings(current_settings): + current_settings["caching"] = str(current_settings["caching"]) + + if "valid" in current_settings: + current_settings.pop("valid") + + with open(configFile, 'w') as file: + yaml.dump(current_settings, file) + +""" ---------------------------------------------------------------- +""" +def version_check(current_settings): + def to_version_string(version_object): + if version_object is None: + return "Undefined_version" + return f'{version_object["major"]}-{version_object["minor"]}-{version_object["series"]}' + + def update_from(old): + if old == None: + if "pagination_offset" not in current_settings: + current_settings["pagination_offset"] = current_settings.pop("paginationOffset") + if "session_key" not in current_settings: + current_settings["session_key"] = secrets.token_hex() + + else: + raise ValueError(f"Updating from {to_version_string(old)} to {to_version_string(version)} is unsupported") + + current_settings["version"] = version + write_settings(current_settings) + logging.info(f"Configuration updated from {to_version_string(old)} to {to_version_string(version)}") + + def compare_versions(old, new): + if old["major"] < new["major"]: + return -1, old["series"] == new["series"] + elif old["major"] > new["major"]: + return 1, old["series"] == new["series"] + elif old["minor"] < new["minor"]: + return -1, old["series"] == new["series"] + elif old["minor"] > new["minor"]: + return 1, old["series"] == new["series"] + return 0, old["series"] == new["series"] + + if "version" not in current_settings: + update_from(None) + + version_diff = compare_versions(current_settings["version"], version) + + if current_settings["version"] == version: + return + elif version_diff[0] == -1: + update_from(current_settings["version"]) + elif version_diff[0] == 1: + raise ValueError("Downgrading configuration versions is unsupported: " + + f"from {to_version_string(current_settings['version'])} to {to_version_string(version)}") + + global settings + settings = current_settings + +# default reports definition +reports = {'pull_request_reports': [{'url': 'pull_request_reports/average_commits_per_PR/', 'description': 'Average commits per pull request'}, {'url': 'pull_request_reports/average_comments_per_PR/', 'description': 'Average comments per pull request'}, {'url': 'pull_request_reports/PR_counts_by_merged_status/', 'description': 'Pull request counts by merged status'}, {'url': 'pull_request_reports/mean_response_times_for_PR/', 'description': 'Mean response times for pull requests'}, {'url': 'pull_request_reports/mean_days_between_PR_comments/', 'description': 'Mean days between pull request comments'}, {'url': 'pull_request_reports/PR_time_to_first_response/', 'description': 'Pull request time until first response'}, {'url': 'pull_request_reports/average_PR_events_for_closed_PRs/', 'description': 'Average pull request events for closed pull requests'}, {'url': 'pull_request_reports/Average_PR_duration/', 'description': 'Average pull request duration'}], 'contributor_reports': [{'url': 'contributor_reports/new_contributors_bar/', 'description': 'New contributors bar graph'}, {'url': 'contributor_reports/returning_contributors_pie_chart/', 'description': 'Returning contributors pie chart'}], 'contributor_reports_stacked': [{'url': 'contributor_reports/new_contributors_stacked_bar/', 'description': 'New contributors stacked bar chart'}, {'url': 'contributor_reports/returning_contributors_stacked_bar/', 'description': 'Returning contributors stacked bar chart'}]} + +# Initialize logging +def init_logging(): + format = "%(asctime)s: %(message)s" + global logger + logger = logging.getLogger("augur view") + logger.setLevel("DEBUG") + logging.basicConfig(filename="augur_view.log", filemode='a', format=format, level=logging.INFO, datefmt="%H:%M:%S") diff --git a/augur/api/view/routes.py b/augur/api/view/routes.py new file mode 100644 index 0000000000..5fe14b94e3 --- /dev/null +++ b/augur/api/view/routes.py @@ -0,0 +1,276 @@ +from flask import Flask, render_template, render_template_string, request, abort, jsonify, redirect, url_for, session, flash +from .utils import * +from flask_login import login_user, logout_user, current_user, login_required +from .server import User +from .server import LoginException + +# ROUTES ----------------------------------------------------------------------- + +def create_routes(server): + """ ---------------------------------------------------------------- + root: + This route returns a redirect to the application root, appended + by the provided path, if any. + """ + @server.app.route('/root/') + @server.app.route('/root/') + def root(path=""): + return redirect(getSetting("approot") + path) + + """ ---------------------------------------------------------------- + logo: + this route returns a redirect to the application logo associated + with the provided brand, otherwise the inverted Augur logo if no + brand is provided. + """ + @server.app.route('/logo/') + @server.app.route('/logo/') + def logo(brand=None): + if brand is None: + return redirect(url_for('static', filename='img/augur_logo.png')) + elif "augur" in brand: + return logo(None) + elif "chaoss" in brand: + return redirect(url_for('static', filename='img/Chaoss_Logo_white.png')) + return "" + + """ ---------------------------------------------------------------- + default: + table: + This route returns the default view of the application, which + is currently defined as the repository table view + """ + @server.app.route('/') + @server.app.route('/repos/views/table') + def repo_table_view(): + query = request.args.get('q') + page = request.args.get('p') + sorting = request.args.get('s') + rev = request.args.get('r') + if rev is not None: + if rev == "False": + rev = False + elif rev == "True": + rev = True + else: + rev = False + + if current_user.is_authenticated: + data = requestJson("repos", cached = False) + user_repo_ids = current_user.query_repos() + user_repos = [] + for repo in data: + if repo["repo_id"] in user_repo_ids: + user_repos.append(repo) + + data = user_repos or None + else: + data = requestJson("repos") + + #if not cacheFileExists("repos.json"): + # return renderLoading("repos/views/table", query, "repos.json") + + return renderRepos("table", query, data, sorting, rev, page, True) + + """ ---------------------------------------------------------------- + card: + This route returns the repository card view + """ + @server.app.route('/repos/views/card') + def repo_card_view(): + query = request.args.get('q') + return renderRepos("card", query, requestJson("repos"), filter = True) + + """ ---------------------------------------------------------------- + groups: + This route returns the groups table view, listing all the current + groups in the backend + """ + @server.app.route('/groups') + @server.app.route('/groups/') + def repo_groups_view(group=None): + query = request.args.get('q') + page = request.args.get('p') + + if(group is not None): + query = group + + if(query is not None): + buffer = [] + data = requestJson("repos") + for repo in data: + if query == str(repo["repo_group_id"]) or query in repo["rg_name"]: + buffer.append(repo) + return renderRepos("table", query, buffer, page = page, pageSource = "repo_groups_view") + else: + groups = requestJson("repo-groups") + return render_template('index.html', body="groups-table", title="Groups", groups=groups, query_key=query, api_url=getSetting('serving')) + + """ ---------------------------------------------------------------- + status: + This route returns the status view, which displays information + about the current status of collection in the backend + """ + @server.app.route('/status') + def status_view(): + return render_module("status", title="Status") + + """ ---------------------------------------------------------------- + login: + Under development + """ + @server.app.route('/account/login', methods=['GET', 'POST']) + def user_login(): + if request.method == 'POST': + try: + user_id = request.form.get('username') + remember = request.form.get('remember') is not None + if user_id is None: + raise LoginException("A login issue occurred") + + user = User(user_id) + + if request.form.get('register') is not None: + if user.exists: + raise LoginException("User already exists") + if not user.register(request): + raise LoginException("An error occurred registering your account") + else: + flash("Account successfully created") + + if user.validate(request) and login_user(user, remember = remember): + flash(f"Welcome, {user_id}!") + if "login_next" in session: + return redirect(session.pop("login_next")) + return redirect(url_for('root')) + else: + raise LoginException("Invalid login credentials") + except LoginException as e: + flash(str(e)) + return render_module('login', title="Login") + + """ ---------------------------------------------------------------- + logout: + Under development + """ + @server.app.route('/account/logout') + @login_required + def user_logout(): + logout_user() + flash("You have been logged out") + return redirect(url_for('root')) + + @server.app.route('/account/delete') + @login_required + def user_delete(): + if current_user.delete(): + flash(f"Account {current_user.id} successfully removed") + logout_user() + else: + flash("An error occurred removing the account") + + return redirect(url_for("root")) + + @server.app.route('/account/update') + @login_required + def user_update_password(): + if current_user.update_password(request): + flash(f"Account {current_user.id} successfully updated") + else: + flash("An error occurred updating the account") + + return redirect(url_for("user_settings")) + + """ ---------------------------------------------------------------- + settings: + Under development + """ + @server.app.route('/account/settings') + @login_required + def user_settings(): + return render_module("settings", title="Settings") + + """ ---------------------------------------------------------------- + report page: + This route returns a report view of the requested repo (by ID). + """ + @server.app.route('/repos/views/repo/') + def repo_repo_view(id): + # For some reason, there is no reports definition (shouldn't be possible) + if reports is None: + return renderMessage("Report Definitions Missing", "You requested a report for a repo on this instance, but a definition for the report layout was not found.") + data = requestJson("repos") + repo = {} + # Need to convert the repo id parameter to int so it's comparable + try: + id = int(id) + except: + pass + # Finding the report object in the data so the name is accessible on the page + for item in data: + if item['repo_id'] == id: + repo = item + break + + return render_module("repo-info", reports=reports.keys(), images=reports, title="Repo", repo=repo, repo_id=id) + + """ ---------------------------------------------------------------- + default: + table: + This route returns the default view of the application, which + is currently defined as the repository table view + """ + @server.app.route('/user/group/') + def user_group_view(group): + params = {} + + # NOT IMPLEMENTED + # query = request.args.get('q') + + try: + params["page"] = int(request.args.get('p')) + except: + pass + + if sort := request.args.get('s'): + params["sort"] = sort + + rev = request.args.get('r') + if rev is not None: + if rev == "False": + params["direction"] = "ASC" + elif rev == "True": + params["direction"] = "DESC" + + if current_user.is_authenticated: + data = current_user.select_group(group, **params) + + if not data: + return renderMessage("Error Loading Group", "Either the group you requestion does not exist, or an unspecified error occurred.") + else: + return renderMessage("Authentication Required", "You must be logged in to view this page.") + + #if not cacheFileExists("repos.json"): + # return renderLoading("repos/views/table", query, "repos.json") + + return renderRepos("table", None, data, sort, rev, params.get("page"), True) + + """ ---------------------------------------------------------------- + Admin dashboard: + View the admin dashboard. + """ + @server.app.route('/dashboard') + def dashboard_view(): + empty = [ + { "title": "Placeholder", "settings": [ + { "id": "empty", + "display_name": "Empty Entry", + "value": "NULL", + "description": "There's nothing here 👻" + } + ]} + ] + + backend_config = requestJson("config/get", False) + + return render_template('admin-dashboard.html', sections = empty, config = backend_config) diff --git a/augur/api/view/run.sh b/augur/api/view/run.sh new file mode 100755 index 0000000000..12070743db --- /dev/null +++ b/augur/api/view/run.sh @@ -0,0 +1,11 @@ +export CONFIG_LOCATION="config.yml" +export SERVER_ADDRESS="0.0.0.0" +export SERVER_PORT="8000" + +# Notify the bootstrapper not to generate a Gunicorn config +# Also launch with the development server +export DEVELOPMENT=1 + +export TEMPLATES_AUTO_RELOAD=True + +python3 bootstrap.py diff --git a/augur/api/view/server/Environment.py b/augur/api/view/server/Environment.py new file mode 100644 index 0000000000..409a5975e5 --- /dev/null +++ b/augur/api/view/server/Environment.py @@ -0,0 +1,52 @@ +import os + +class Environment: + """ + This class is used to make dealing with environment variables easier. It + allows you to set multiple environment variables at once, and to get items + with subscript notation without needing to deal with the particularities of + non-existent values. + """ + def __init__(self, **kwargs): + for (key, value) in kwargs.items(): + self[key] = value + + def setdefault(self, key, value): + if not self[key]: + self[key] = value + return value + return self[key] + + def setall(self, **kwargs): + result = {} + for (key, value) in kwargs.items(): + if self[key]: + result[key] = self[key] + self[key] = value + + def getany(self, *args): + result = {} + for arg in args: + if self[arg]: + result[arg] = self[arg] + return result + + def as_type(self, type, key): + if self[key]: + return type(self[key]) + return None + + def __getitem__(self, key): + return os.getenv(key) + + def __setitem__(self, key, value): + os.environ[key] = str(value) + + def __len__(self)-> int: + return len(os.environ) + + def __str__(self)-> str: + return str(os.environ) + + def __iter__(self): + return (item for item in os.environ.items) \ No newline at end of file diff --git a/augur/api/view/server/LoginException.py b/augur/api/view/server/LoginException.py new file mode 100644 index 0000000000..f13a31fc06 --- /dev/null +++ b/augur/api/view/server/LoginException.py @@ -0,0 +1,3 @@ + +class LoginException(Exception): + pass diff --git a/augur/api/view/server/ServerThread.py b/augur/api/view/server/ServerThread.py new file mode 100644 index 0000000000..af3651a8f1 --- /dev/null +++ b/augur/api/view/server/ServerThread.py @@ -0,0 +1,35 @@ +from werkzeug.debug import DebuggedApplication +from werkzeug.serving import make_server +import threading + +class ServerThread(threading.Thread): + """ + Create a runnable Flask server app that automatically launches on a separate + thread. + """ + def __init__(self, app, port = 5000, address = "0.0.0.0", reraise = False): + threading.Thread.__init__(self) + + # Required to enable debugging with make_server + if reraise: + app.config['PROPAGATE_EXCEPTIONS'] = True + app.config['TESTING'] = True + app.config['DEBUG'] = True + app.config['TRAP_HTTP_EXCEPTIONS'] = True + app.config['TEMPLATES_AUTO_RELOAD'] = True + + debug_app = DebuggedApplication(app, True) + + self.server = make_server(address, port, debug_app, threaded = True) + self.ctx = app.app_context() + self.ctx.push() + + # For compatibility with subprocesses + self.terminate = self.shutdown + self.wait = self.join + + def run(self): + self.server.serve_forever() + + def shutdown(self): + self.server.shutdown() \ No newline at end of file diff --git a/augur/api/view/server/User.py b/augur/api/view/server/User.py new file mode 100644 index 0000000000..4889922ce1 --- /dev/null +++ b/augur/api/view/server/User.py @@ -0,0 +1,276 @@ +from flask_login import UserMixin +# I'm using requests here to avoid circular integration with utils +import requests, time, re + +""" ---------------------------------------------------------------- +""" +class User(UserMixin): + # User.api is set in utils.py + # User.logger is set in utils.py + + @property + def is_authenticated(self): + return self._is_authenticated + + @is_authenticated.setter + def is_authenticated(self, val): + self._is_authenticated = val + + @property + def is_active(self): + return self._is_active + + @is_active.setter + def is_active(self, val): + self._is_active = val + + @property + def is_anoymous(self): + return self._is_anoymous + + @is_anoymous.setter + def is_anoymous(self, val): + self._is_anoymous = val + + @property + def exists(self): + return self._exists + + @property + def default_group(self): + if not self.is_authenticated: + return None + elif self._default_group: + return self._default_group + + group_name = self.id + "_default" + groups = self.get_groups() + + if group_name not in groups: + if not self.add_repo_group(group_name): + User.logger.warning("Default user group does not exist, and could not be created") + return None + + self._default_group = group_name + return group_name + + def __init__(self, id): + # flask_login requires that the id be of type string + self.id = str(id) + self._exists = False + self._is_anonymous = False + self._is_authenticated = False + self._is_active = False + self._default_group = None + + # Query the server for the existence of this user + self.query_user() + + def query_user(self): + if self._exists: + # User has already been queried and verified to exist + return True + + endpoint = User.api + "/user/query" + + response = requests.post(endpoint, params = {"username": self.id}) + + if response.status_code == 200 and response.json().get("status") == True: + self._exists = True + return True + + return False + + def get_id(self): + return self.id + + def query_repos(self, group = None): + endpoint = User.api + "/user/repos" + + if not group: + group = self.default_group + + response = requests.post(endpoint, params = {"username": self.id}) + + if response.status_code == 200: + data = response.json() + if data.get("status") == "success": + return data.get("repo_ids") + else: + User.logger.warning(f"Could not get user repos: {data.get('status')}") + else: + User.logger.warning(f"Could not get user repos: {response.status_code}") + + def try_add_url(self, url, group = None): + repo = re.search("https?:\/\/github\.com\/([A-Za-z0-9 \- _]+)\/([A-Za-z0-9 \- _]+)(.git)?\/?$", url) + org = re.search("https?:\/\/github\.com\/([A-Za-z0-9 \- _]+)\/?$", url) + + if repo: + return self.add_repo(url, group) + elif org: + return self.add_org(url, group) + + return False + + def add_repo(self, url, group = None): + endpoint = User.api + "/user/add_repo" + + if not group: + group = self.default_group + + response = requests.post(endpoint, params = {"username": self.id, "repo_url": url}) + + if response.status_code == 200: + data = response.json() + if data.get("status") == "Repo Added": + return True + else: + User.logger.warning(f"Could not add user repo {url}: {data.get('status')}") + else: + User.logger.warning(f"Could not add user repo {url}: {response.status_code}") + + return False + + def add_org(self, url, group = None): + endpoint = User.api + "/user/add_org" + + response = requests.post(endpoint, params = {"username": self.id, "org_url": url}) + + if response.status_code == 200: + data = response.json() + if data.get("status") == "Org repos added": + return True + else: + User.logger.warning(f"Could not add user org {url}: {data.get('status')}") + else: + User.logger.warning(f"Could not add user org {url}: {response.status_code}") + + return False + + def get_groups(self): + endpoint = User.api + "/user/groups" + + response = requests.post(endpoint, params = {"username": self.id}) + + if response.status_code == 200: + return response.json() + else: + data = response.json() + User.logger.warning(f"Could not get user groups: {data.get('status')}") + + def add_repo_group(self, group_name): + endpoint = User.api + "/user/add_group" + + response = requests.post(endpoint, params = {"username": self.id, "group_name": group_name}) + + if response.status_code == 200: + data = response.json() + if data.get("status") == "Group created": + return True + else: + User.logger.warning(f"Could not add user group: {data.get('status')}") + else: + User.logger.warning(f"Could not add user group: {response.status_code}") + + def remove_repo_group(self, group_name): + endpoint = User.api + "/user/remove_group" + + response = requests.post(endpoint, params = {"username": self.id, "group_name": group_name}) + + if response.status_code == 200: + data = response.json() + if data.get("status") == "Group deleted": + return True + else: + User.logger.warning(f"Could not remove user group: {data.get('status')}") + else: + User.logger.warning(f"Could not remove user group: {response.status_code}") + + def select_group(self, group_name, **kwargs): + endpoint = User.api + "/user/group_repos" + + kwargs["username"] = self.id + kwargs["group_name"] = group_name + + response = requests.post(endpoint, params = kwargs) + + if response.status_code == 200: + return response.json() + elif response.status_code == 400: + data = response.json() + User.logger.warning(f"Could not select user group {group_name}: {data.get('status')}") + else: + User.logger.warning(f"Could not select user group {group_name}: {response.status_code}") + + def register(self, request): + endpoint = User.api + "/user/create" + + data = request.form.to_dict() + + # admin creation is CLI only for now + if "create_admin" in data: + data.pop("create_admin") + + response = requests.post(endpoint, params = request.form.to_dict()) + + if response.status_code == 200: + return True + elif response.status_code != 200: + User.logger.debug(f"Could not register user: {response.status_code}") + else: # :/ + User.logger.debug(f"Could not register user: {response.json()['status']}") + + return False + + def validate(self, request): + endpoint = User.api + "/user/validate" + + response = requests.post(endpoint, params = request.form.to_dict()) + + if response.status_code == 200 and response.json()["status"] == "Validated": + self._is_authenticated = True + self._is_active = True + return True + elif response.status_code != 200: + User.logger.debug(f"Could not validate user: {response.status_code}") + else: + User.logger.debug(f"Could not validate user: {response.json()['status']}") + + + # Avoid abuse by malicious actors + time.sleep(2) + return False + + def update_password(self, request): + endpoint = User.api + "/user/update" + + data = request.form.to_dict() + data["username"] = self.id + + response = requests.post(endpoint, params = data) + + if response.status_code == 200 and "Updated" in response.json()["status"]: + return True + elif response.status_code != 200: + User.logger.debug(f"Could not update user password: {response.status_code}") + else: + User.logger.debug(f"Could not update user password: {response.json()['status']}") + + return False + + def delete(self): + endpoint = User.api + "/user/remove" + + response = requests.delete(endpoint, params = {"username": self.id}) + + if response.status_code == 200: + return True + elif response.status_code != 200: + User.logger.debug(f"Could not remove user: {response.status_code}") + else: + User.logger.debug(f"Could not remove user: {response.json()['status']}") + + return False + + def __str__(self) -> str: + return f"" diff --git a/augur/api/view/server/__init__.py b/augur/api/view/server/__init__.py new file mode 100644 index 0000000000..287457c4fd --- /dev/null +++ b/augur/api/view/server/__init__.py @@ -0,0 +1,4 @@ +from .Environment import Environment +from .User import User +from .ServerThread import ServerThread +from .LoginException import LoginException diff --git a/augur/api/view/url_converters.py b/augur/api/view/url_converters.py new file mode 100644 index 0000000000..4d43a411f6 --- /dev/null +++ b/augur/api/view/url_converters.py @@ -0,0 +1,27 @@ +from werkzeug.routing import BaseConverter +import json + +class ListConverter(BaseConverter): + def to_python(self, value): + return value.split('+') + + def to_url(self, values): + return '+'.join(BaseConverter.to_url(value) + for value in values) + +class BoolConverter(BaseConverter): + def to_python(self, value): + if value == "False": + return False + elif value == "True": + return True + + def to_url(self, value): + return str(value) + +class JSONConverter(BaseConverter): + def to_python(self, value): + return json.loads(value) + + def to_url(self, value): + return json.dumps(value) diff --git a/augur/api/view/utils.py b/augur/api/view/utils.py new file mode 100644 index 0000000000..9ae6137ad3 --- /dev/null +++ b/augur/api/view/utils.py @@ -0,0 +1,435 @@ +from pathlib import Path +from concurrent.futures import ThreadPoolExecutor +from flask import render_template, flash +from .init import * +from .server import User +import urllib.request, urllib.error, json, os, math, yaml, urllib3, time, logging, re + +def parse_url(url): + from urllib.parse import urlparse + + # localhost is not a valid host + if "localhost" in url: + url = url.replace("localhost", "127.0.0.1") + + if not url.startswith("http"): + url = f"http://{url}" + + parts = urlparse(url) + directories = parts.path.strip('/').split('/') + queries = parts.query.strip('&').split('&') + + elements = { + 'scheme': parts.scheme, + 'netloc': parts.netloc, + 'path': parts.path, + 'params': parts.params, + 'query': parts.query, + 'fragment': parts.fragment + } + + return elements, directories, queries + +def validate_api_url(url): + from urllib.parse import urlunparse + + parts = parse_url(url)[0] + + if not parts["scheme"]: + parts["scheme"] = "http" + + staged_url = urlunparse(parts.values()) + + def is_status_ok(): + try: + with urllib.request.urlopen(staged_url) as request: + response = json.loads(request.read().decode()) + if "status" in response: + return request.url + except Exception as e: + logging.error(f"Error during serving URL verification: {str(e)}") + + return False + + status = is_status_ok() + if not status: + if "/api/unstable" not in parts["path"]: + # The URL does not point directly to the API + # try once more with a new suffix + parts["path"] = str(Path(parts["path"]).joinpath("api/unstable")) + staged_url = urlunparse(parts.values()) + + status = is_status_ok() + if not status: + # The URL does not point to a valid augur instance + return "" + else: + return status + else: + return "" + + return status + + +""" ---------------------------------------------------------------- +loadSettings: + This function attempts to load the application settings from the config file + (defined in init.py). It is assumed that the filename or file path defined + during initialization is sufficient to locate the config file, and that the + current process has read access to that file. + + If loading the config file fails, default settings are loaded via + init_settings() and an attempt is made to write default settings to the + provided config file. +""" +def loadSettings(): + global settings + configFilePath = Path(configFile) + if not configFilePath.is_file(): + init_settings() + with open(configFile, 'w') as file: + logging.info(f"Generating default configuration file: {configFile}") + yaml.dump(settings, file) + logging.info("Default configuration file successfully generated.") + else: + with open(configFilePath) as file: + settings = yaml.load(file, Loader=yaml.FullLoader) + + # Ensure that the cache directory exists and is valid + cachePath = Path(settings["caching"]) + if not cachePath.is_dir(): + if cachePath.is_file(): + raise Exception(f"Cannot initialize caching: cache path [{cachePath}] is a file") + else: + try: + cachePath.mkdir(parents=True) + logging.info("cache directory initialized") + except Exception as err: + raise Exception(f"Cannot initialize caching: could not create cache directory [{cachePath}]") + + # Use the resolved path for cache directory access + settings["caching"] = cachePath + + staged_url = validate_api_url(settings["serving"]) + if staged_url: + settings["serving"] = re.sub("/$", "", staged_url) + settings["valid"] = True + else: + settings["valid"] = False + raise ValueError(f"The provided serving URL is not valid: {settings['serving']}") + +""" ---------------------------------------------------------------- +""" +def getSetting(key): + if key == "serving": + return "http://127.0.0.1:5000/api/unstable" + return settings[key] + +init_logging() + +loadSettings() + +from .init import logger + +User.api = getSetting("serving") +User.logger = logger + +version_check(settings) + +""" ---------------------------------------------------------------- +""" +def loadReports(): + global reports + try: + with open(getSetting("reports")) as file: + reports = yaml.load(file, Loader=yaml.FullLoader) + id = -1 + for report in reports: + for image in reports[report]: + image['id'] = id = id + 1 + return True + except Exception as err: + logging.error(f"An exception occurred reading reports endpoints from [{getSetting('reports')}]:") + logging.error(err) + try: + with open(getSetting("reports"), 'w') as file: + logging.info("Attempting to generate default reports.yml") + yaml.dump(reports, file) + logging.info("Default reports file successfully generated.") + except Exception as ioErr: + logging.error("Error creating default report configuration:") + logging.error(ioErr) + return False + +loadReports() +cache_files_requested = [] + +""" ---------------------------------------------------------------- +""" +def cacheFileExists(filename): + cache_file = Path(filename) + if cache_file.is_file(): + if(getSetting('cache_expiry') > 0): + cache_file_age = time.time() - cache_file.stat().st_mtime + if(cache_file_age > getSetting('cache_expiry')): + try: + cache_file.unlink() + logging.info(f"Cache file {filename} removed due to expiry") + return False + except Exception as e: + logging.error("Error: cache file age exceeds expiry limit, but an exception occurred while attempting to remove") + logging.error(e) + return True + else: + return False + +def stripStatic(url): + return url.replace("static/", "") + +""" ---------------------------------------------------------------- +""" +def toCacheFilename(endpoint): + return endpoint.replace("/", ".").replace("?", "_").replace("=", "_") + '.agcache' + +def toCacheFilepath(endpoint): + return getSetting('caching').joinpath(toCacheFilename(endpoint)) + +def toCacheURL(endpoint): + return getSetting('approot') + str(toCacheFilepath(endpoint)) + +""" ---------------------------------------------------------------- +requestJson: + Attempts to load JSON data from cache for the given endpoint. + If no cache file is found, a request is made to the URL for + the given endpoint and, if successful, the resulting JSON is + cached for future use. Cached files will be stored with all + '/' characters replaced with '.' for filesystem compatibility. + +@PARAM: endpoint: String + A String representation of the requested + json endpoint (relative to the api root). + +@RETURN: data: JSON + An object representing the JSON data read + from either the cache file or the enpoint + URL. Will return None if an error isreturn None + encountered. +""" +def requestJson(endpoint, cached = True): + filename = toCacheFilepath(endpoint) + requestURL = getSetting('serving') + "/" + endpoint + logging.info(f'requesting json from: {endpoint}') + try: + if cached and cacheFileExists(filename): + with open(filename) as f: + data = json.load(f) + else: + with urllib.request.urlopen(requestURL) as url: + if url.getcode() != 200: + raise urllib.error.HTTPError(code = url.getcode()) + + data = json.loads(url.read().decode()) + + if cached: + with open(filename, 'w') as f: + json.dump(data, f) + if filename in cache_files_requested: + cache_files_requested.remove(filename) + return data + except Exception as err: + logging.error("An exception occurred while fulfilling a json request") + logging.error(err) + return False, str(err) + +""" ---------------------------------------------------------------- +""" +def requestPNG(endpoint): + filename = toCacheFilepath(endpoint) + requestURL = getSetting('serving') + "/" + endpoint + try: + if cacheFileExists(filename): + return toCacheURL(endpoint) + else: + urllib.request.urlretrieve(requestURL, filename) + if filename in cache_files_requested: + cache_files_requested.remove(filename) + return toCacheURL(endpoint) + except Exception as err: + logging.error("An exception occurred while fulfilling a png request") + logging.error(err) + +""" ---------------------------------------------------------------- +""" +def download(url, cmanager, filename, image_cache, image_id, repo_id = None): + image_cache[image_id] = {} + image_cache[image_id]['filename'] = filename + filename = toCacheFilepath(filename) + if cacheFileExists(filename): + image_cache[image_id]['exists'] = True + return + response = cmanager.request('GET', url) + if "json" in response.headers['Content-Type']: + logging.warn(f"repo {repo_id}: unexpected json response in image request") + logging.warn(f" response: {response.data.decode('utf-8')}") + image_cache[image_id]['exists'] = False + return + if response and response.status == 200: + image_cache[image_id]['exists'] = True + try: + with open(filename, 'wb') as f: + f.write(response.data) + except Exception as err: + logging.error("An exception occurred writing a cache file to disk") + logging.error(err) + +""" ---------------------------------------------------------------- +""" +def requestReports(repo_id): + # If this request has already been fulfilled, no need to process it again + if(repo_id in report_requests.keys()): + return + + # initialize a new request entry to hold the resulting data + report_requests[repo_id] = {} + report_requests[repo_id]['complete'] = False + + """ ---------- + If the report definition could not be loaded, we cannot determine what + files to request from the backend to compose the report. Returning here + causes the completion status of the request to be False, which will + display an error message when sent to the frontend. + """ + if reports is None: + return + + threadPools = [] + reportImages = {} + for report in reports: + # Reports is a dictionary of lists, so we get the size of each list + size = len(reports[report]) + + # Set up various threading components to manage image downloading + connection_mgr = urllib3.PoolManager(maxsize=size) + thread_pool = ThreadPoolExecutor(size) + threadPools.append(thread_pool) + + for image in reports[report]: + # Where should the downloaded image be stored (in cache) + filename = toCacheFilename(f"{image['url']}?repo_id={repo_id}") + # Where are we downloading the image from + image_url = f"{getSetting('serving')}/{image['url']}?repo_id={repo_id}" + # Add a request for this image to the thread pool using the download function + thread_pool.submit(download, image_url, connection_mgr, filename, reportImages, image['id'], repo_id) + + # Wait for all connections to resolve, then clean up + for thread_pool in threadPools: + thread_pool.shutdown() + + report_requests[repo_id]['images'] = reportImages + + # Remove the request from the queue when completed + report_requests[repo_id]['complete'] = True + +""" ---------------------------------------------------------------- +renderRepos: + This function renders a list of repos using a given view, while passing query + data along. This function also processes pagination automatically for the + range of data provided. If a query is provided and filtering is enabled, the + data will be filtered using the 'repo_name', 'repo_group_id' or 'rg_name'. +@PARAM: view: String + A string representing the template to use for displaying the repos. +@PARAM: query: String + The query argument from the previous page. +@PARAM: data: Dictionary + The repo data to display on the page +@PARAM: sorting: String = None + The key in the data to sort by +@PARAM: rev: Boolean = False + Determines if the sorted data should be displayed in descending order +@PARAM: page: String = None + The current page to use within pagination +@PARAM: filter: Boolean = False + Filter data using query +@PARAM: pageSource: String = "repos/views/table" + The base url to use for the page links +""" +def renderRepos(view, query, data, sorting = None, rev = False, page = None, filter = False, pageSource = "repo_table_view", sortBasis = None): + pagination_offset = getSetting('pagination_offset') + + """ ---------- + If the data does not exist, we cannot construct the table to display on + site. Rendering the table module without data displays an error message + """ + if(data is None): + return render_template('index.html', body="repos-" + view, title="Repos") + + # If a query exists and filtering is set to true, attempt to filter the data + if((query is not None) and filter): + results = [] + for repo in data: + if (query in repo["repo_name"]) or (query == str(repo["repo_group_id"])) or (query in repo["rg_name"]): + results.append(repo) + data = results + + # Determine the maximum number of pages which can be displayed from the data + pages = math.ceil(len(data) / pagination_offset) + + if page is not None: + page = int(page) + else: + page = 1 + + """ ---------- + Caller requested sorting of the data. The data is a list of dictionaries + with numerous sortable elements, and the "sorting" parameter is assumed + to be the key of the desired element in the dictionary by which to sort. + + We need the "or 0" here to ensure the comparison is valid for rows which + do not have data for the requested column (we're making the assumption + that the data type is comparable to integer 0). + """ + if sorting is not None: + try: + data = sorted(data, key = lambda i: i[sorting] or 0, reverse = rev) + except Exception as e: + flash("An error occurred during sorting") + logger.error(str(e)) + + """ ---------- + Here we extract a subset of the data for display on the site. First we + calculate the start index within the data of our current "page" (x), + then we index to that position plus the pagination offset (or page size) + defined above. The result is a list which contains *at most* a number of + entries equal to the pagination offset + """ + x = pagination_offset * (page - 1) + data = data[x: x + pagination_offset] + + return render_module("repos-" + view, title="Repos", repos=data, query_key=query, activePage=page, pages=pages, offset=pagination_offset, PS=pageSource, reverse = rev, sorting = sorting) + +""" ---------------------------------------------------------------- + Renders a simple page with the given message information, and optional page + title and redirect +""" +def renderMessage(messageTitle, messageBody, title = None, redirect = None, pause = None): + return render_module("notice", messageTitle=messageTitle, messageBody=messageBody, title=title, redirect=redirect, pause=pause) + +""" ---------------------------------------------------------------- +""" +def render_module(module, **args): + # args.setdefault("title", "Augur View") + args.setdefault("api_url", getSetting("serving")) + args.setdefault("body", module) + + if not getSetting("valid"): + args.setdefault("invalid", True) + + return render_template('index.html', **args) + +""" ---------------------------------------------------------------- + No longer used +""" +# My attempt at a loading page +def renderLoading(dest, query, request): + cache_files_requested.append(request) + return render_template('index.html', body="loading", title="Loading", d=dest, query_key=query, api_url=getSetting('serving')) diff --git a/augur/static/css/dashboard.css b/augur/static/css/dashboard.css new file mode 100644 index 0000000000..6be0de9643 --- /dev/null +++ b/augur/static/css/dashboard.css @@ -0,0 +1,45 @@ +:root { + --color-bg: #1A233A; + --color-bg-light: #272E48; + --color-fg: white; + --color-fg-contrast: black; + --color-accent: #6f42c1; + --color-accent-dark: #6134b3; + --color-notice: #00ddff; + --color-notice-contrast: #006979; +} + +body { + background-color: var(--color-bg); + color: var(--color-fg); + overflow: hidden; +} + +.content-column { + overflow-y: scroll; +} + +.dashboard-content { + background-color: var(--color-bg-light); + margin-top: 10px; + margin-bottom: 10px; +} + +.nav-pills .nav-link.active, .nav-pills .show > .nav-link { + background-color: var(--color-accent); +} + +.dashboard-sidebar { + width: 280px; + background-color: var(--color-bg-light) !important; +} + +.form-control { + border: 1px solid #596280; + -webkit-border-radius: 2px; + -moz-border-radius: 2px; + border-radius: 2px; + font-size: .825rem; + background: #1A233A; + color: #bcd0f7; +} diff --git a/augur/static/css/first_time.css b/augur/static/css/first_time.css new file mode 100644 index 0000000000..12f8ae9f54 --- /dev/null +++ b/augur/static/css/first_time.css @@ -0,0 +1,148 @@ +body{ + margin-top:20px; + color: #bcd0f7; + background: #1A233A; +} +h1 { + font-size: 2rem; +} +.sidebar .sidebar-top { + margin: 0 0 1rem 0; + padding-bottom: 1rem; + text-align: center; +} +.sidebar .sidebar-top .brand-logo { + margin: 0 0 1rem 0; +} +.sidebar .sidebar-top .brand-logo img { + height: 90px; + -webkit-border-radius: 100px; + -moz-border-radius: 100px; + border-radius: 100px; +} +.sidebar .about { + margin: 1rem 0 0 0; + font-size: 0.8rem; + text-align: center; +} +.card { + background: #272E48; + -webkit-border-radius: 5px; + -moz-border-radius: 5px; + border-radius: 5px; + border: 0; + margin-bottom: 1rem; +} +.form-control { + border: 1px solid #596280; + -webkit-border-radius: 2px; + -moz-border-radius: 2px; + border-radius: 2px; + font-size: .825rem; + background: #1A233A; + color: #bcd0f7; +} +.modal-content { + color: black; +} +.editor-container { + height: 300px !important; +} + +.spinner { + -webkit-animation: rotator 1.4s linear infinite; + animation: rotator 1.4s linear infinite; +} + +.spinner-container { + display: flex; + align-items: center; + justify-content: center; +} + +@-webkit-keyframes rotator { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(270deg); + } +} + +@keyframes rotator { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(270deg); + } +} +.path { + stroke-dasharray: 187; + stroke-dashoffset: 0; + transform-origin: center; + -webkit-animation: dash 1.4s ease-in-out infinite, colors 5.6s ease-in-out infinite; + animation: dash 1.4s ease-in-out infinite, colors 5.6s ease-in-out infinite; +} + +@-webkit-keyframes colors { + 0% { + stroke: #4285F4; + } + 25% { + stroke: #DE3E35; + } + 50% { + stroke: #F7C223; + } + 75% { + stroke: #1B9A59; + } + 100% { + stroke: #4285F4; + } +} + +@keyframes colors { + 0% { + stroke: #4285F4; + } + 25% { + stroke: #DE3E35; + } + 50% { + stroke: #F7C223; + } + 75% { + stroke: #1B9A59; + } + 100% { + stroke: #4285F4; + } +} +@-webkit-keyframes dash { + 0% { + stroke-dashoffset: 187; + } + 50% { + stroke-dashoffset: 46.75; + transform: rotate(135deg); + } + 100% { + stroke-dashoffset: 187; + transform: rotate(450deg); + } +} +@keyframes dash { + 0% { + stroke-dashoffset: 187; + } + 50% { + stroke-dashoffset: 46.75; + transform: rotate(135deg); + } + 100% { + stroke-dashoffset: 187; + transform: rotate(450deg); + } +} diff --git a/augur/static/css/stylesheet.css b/augur/static/css/stylesheet.css new file mode 100644 index 0000000000..171d86b884 --- /dev/null +++ b/augur/static/css/stylesheet.css @@ -0,0 +1,424 @@ +:root { + --color-bg: #252525; + --color-bg-light: #4a4651; + --color-fg: white; + --color-fg-contrast: black; + --color-accent: #6f42c1; + --color-accent-dark: #6134b3; + --color-notice: #00ddff; + --color-notice-contrast: #006979; + --color-link: #5de4ff +} + +html, +body { + /* IE 10-11 didn't like using min-height */ + height: 100%; +} + +body { + display: flex; + flex-direction: column; + background-color: var(--color-bg); +} + +.hidden { + display: none; +} + +.content { + flex: 1 0 auto; + /* Prevent Chrome, Opera, and Safari from letting these items shrink to smaller than their content's default minimum size. */ + padding: 20px; + padding-top: 100px; + color: var(--color-fg); + text-align: center; +} + +.footer { + flex-shrink: 0; + /* Prevent Chrome, Opera, and Safari from letting these items shrink to smaller than their content's default minimum size. */ + padding: 5px; + background-color: var(--color-bg-light); + width: 100%; +} + +.nav-image { + width: 100px; +} + +.nav-separator { + background-color: var(--color-accent-dark); + max-width: 5px; + min-width: 5px; + height: 35px; +} + +.notification-icon { + height: 30px; + -webkit-filter: invert(100%); + /* safari 6.0 - 9.0 */ + filter: invert(100%); +} + +.submitContainer { + background-color: var(--color-accent); +} + +.toast-container { + top: 80px !important; + right: 16px !important; +} + +.toast { + background-color: var(--color-notice); + color: var(--color-fg); +} + +#toast-placeholder { + display: none; +} + +.dashboard-sidebar { + height: 100%; +} + +.display-table { + background-color: white !important; + overflow: auto; +} + +.paginationActive { + background-color: var(--color-accent-dark); + border-color: var(--color-accent-dark); + color: var(--color-fg); +} + +.sorting-link { + color: var(--color-fg-contrast); + text-decoration: none; +} + +/* User settings page */ + +.user-settings-form { + width: 30%; + min-width: 300px; + margin: auto; +} + +.user-settings-form label { + margin-top: 10px; +} + +.user-card { + background-color: var(--color-bg-light); + margin-bottom: 40px; +} + +.user-card a { + color: var(--color-link); +} + +/* Login Page */ + +#registration-div { + -webkit-transition: opacity 0.5s ease; + -moz-transition: opacity 0.5s ease; + -ms-transition: opacity 0.5s ease; + -o-transition: opacity 0.5s ease; + transition: opacity 0.5s ease; +} + +form i { + pointer-events: none; +} + +.password-toggle { + background-color: var(--color-fg); + border: 1px solid #ced4da; + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} + +/* Image Zoom */ + +img[id^="report_image_"] { + border-radius: 5px; + cursor: pointer; + transition: 0.3s; +} + +img[id^="report_image_"]:hover { + filter: brightness(50%); +} + +/* The Modal (background) */ +.modal { + display: none; + /* Hidden by default */ + position: fixed; + /* Stay in place */ + z-index: 1; + /* Sit on top */ + padding-top: 100px; + /* Location of the box */ + left: 0; + top: 0; + width: 100%; + /* Full width */ + height: 100%; + /* Full height */ + overflow: auto; + /* Enable scroll if needed */ + background-color: rgb(0, 0, 0); + /* Fallback color */ + background-color: rgba(0, 0, 0, 0.9); + /* Black w/ opacity */ +} + +/* Modal Content (image) */ +.modal-content { + margin: auto; + display: block; + width: 70%; +} + +/* Caption of Modal Image */ +#caption { + margin: auto; + display: block; + width: 80%; + max-width: 700px; + text-align: center; + color: #ccc; + padding: 10px 0; + height: 80px; +} + +#close-caption { + margin: auto; + display: block; + width: 80%; + max-width: 700px; + text-align: center; + color: #ccc; + padding: 10px 0; + height: 50px; +} + +/* Add Animation */ +.modal-content, +#caption { + -webkit-animation-name: zoom; + -webkit-animation-duration: 0.6s; + animation-name: zoom; + animation-duration: 0.6s; +} + +@-webkit-keyframes zoom { + from { + -webkit-transform: scale(0) + } + + to { + -webkit-transform: scale(1) + } +} + +@keyframes zoom { + from { + transform: scale(0) + } + + to { + transform: scale(1) + } +} + +/* The Close Button */ +.close { + position: absolute; + top: 45px; + right: 35px; + color: #f1f1f1; + font-size: 40px; + font-weight: bold; + transition: 0.3s; +} + +.close:hover, +.close:focus { + color: #bbb; + text-decoration: none; + cursor: pointer; +} + +/* 100% Image Width on Smaller Screens */ +@media only screen and (max-width: 700px) { + .modal-content { + width: 100%; + } +} + +.img_placeholder { + width: 256px; + height: 256px; +} + +.card-footer-wrap { + width: 256px; + +} + +/* Loading Animation */ + +.dot-flashing { + position: relative; + width: 10px; + height: 10px; + border-radius: 5px; + background-color: #9880ff; + color: #9880ff; + animation: dotFlashing 1s infinite linear alternate; + animation-delay: .5s; +} + +.dot-flashing::before, +.dot-flashing::after { + content: ''; + display: inline-block; + position: absolute; + top: 0; +} + +.dot-flashing::before { + left: -15px; + width: 10px; + height: 10px; + border-radius: 5px; + background-color: #9880ff; + color: #9880ff; + animation: dotFlashing 1s infinite alternate; + animation-delay: 0s; +} + +.dot-flashing::after { + left: 15px; + width: 10px; + height: 10px; + border-radius: 5px; + background-color: #9880ff; + color: #9880ff; + animation: dotFlashing 1s infinite alternate; + animation-delay: 1s; +} + +@keyframes dotFlashing { + 0% { + background-color: #9880ff; + } + + 50%, + 100% { + background-color: #ebe6ff; + } +} + +.spinner { + -webkit-animation: rotator 1.4s linear infinite; + animation: rotator 1.4s linear infinite; +} + +.spinner-container { + display: flex; + align-items: center; + justify-content: center; +} + +@-webkit-keyframes rotator { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(270deg); + } +} + +@keyframes rotator { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(270deg); + } +} +.path { + stroke-dasharray: 187; + stroke-dashoffset: 0; + transform-origin: center; + -webkit-animation: dash 1.4s ease-in-out infinite, colors 5.6s ease-in-out infinite; + animation: dash 1.4s ease-in-out infinite, colors 5.6s ease-in-out infinite; +} + +@-webkit-keyframes colors { + 0% { + stroke: #4285F4; + } + 25% { + stroke: #DE3E35; + } + 50% { + stroke: #F7C223; + } + 75% { + stroke: #1B9A59; + } + 100% { + stroke: #4285F4; + } +} + +@keyframes colors { + 0% { + stroke: #4285F4; + } + 25% { + stroke: #DE3E35; + } + 50% { + stroke: #F7C223; + } + 75% { + stroke: #1B9A59; + } + 100% { + stroke: #4285F4; + } +} +@-webkit-keyframes dash { + 0% { + stroke-dashoffset: 187; + } + 50% { + stroke-dashoffset: 46.75; + transform: rotate(135deg); + } + 100% { + stroke-dashoffset: 187; + transform: rotate(450deg); + } +} +@keyframes dash { + 0% { + stroke-dashoffset: 187; + } + 50% { + stroke-dashoffset: 46.75; + transform: rotate(135deg); + } + 100% { + stroke-dashoffset: 187; + transform: rotate(450deg); + } +} \ No newline at end of file diff --git a/augur/static/favicon/android-chrome-192x192.png b/augur/static/favicon/android-chrome-192x192.png new file mode 100644 index 0000000000000000000000000000000000000000..a85212db065d117d4d440952274b3d1f1a8f2080 GIT binary patch literal 5679 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 literal 0 HcmV?d00001 diff --git a/augur/static/favicon/android-chrome-512x512.png b/augur/static/favicon/android-chrome-512x512.png new file mode 100644 index 0000000000000000000000000000000000000000..ef273efb0baf8b550d0e2f466cd13128db443e5c GIT binary patch literal 12926 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 literal 0 HcmV?d00001 diff --git a/augur/static/favicon/apple-touch-icon.png b/augur/static/favicon/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..c2dd7cb22c4fa3d21655e4bd914b3012d5a63e14 GIT binary patch literal 5236 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 literal 0 HcmV?d00001 diff --git a/augur/static/favicon/favicon-16x16.png b/augur/static/favicon/favicon-16x16.png new file mode 100644 index 0000000000000000000000000000000000000000..23fca33e803e2422d11097afde71c48ca1813cb8 GIT binary patch literal 389 zcmV;00eb$4P)fiy0{Sr-54|wz5$Dp#yArF1Pq9d z5d8q13Goy3?|?)}y;#EVhRa=kclXbGL_|J2ZY+Z^kwJ)zm9XQ+MC21YZfuWc*nIn3 zsqE1V?6@(>AWT$;jAj=`v#Ug85GEvp@Xt-FKL#;_m|^wDDga7AuhpS#??T(!fnKYx z2H3+Xw0r>@7uVQ0zk-%8z#dMs14b(Xv4Hjb0qf}<#0;YqWe0@kLzK$D)<+PYpAE43 zllhIe_dhSXx%&=t@;{*0>L4P>lQZa*D)dSf^7s@)lnOHi%)=`}l*-6mHkV3xKD2ys zDa;gLv?CA^%*WT%f6a#%5D|=aqy~iNgZB@WoyU6zOJOd6WYOEXMh9kZpajg`0J%m7 ji`kJun5;ic>yGmmKqEVxY)Vm900000NkvXXu0mjfc>}1B literal 0 HcmV?d00001 diff --git a/augur/static/favicon/favicon-32x32.png b/augur/static/favicon/favicon-32x32.png new file mode 100644 index 0000000000000000000000000000000000000000..5803b2272a8f242010fdb0cf05debefdc368ed3a GIT binary patch literal 649 zcmV;40(Sk0P))v4@WO7xd?$P4Y@-@?Kfez!&mfLVlmTd=l~l=xkE}Pz8V{0JH%B zBTBnlquQb0KPF#p8$>fmN8hGK`-&n$H4Hu#c z0MMjXCAQ*js8zx4W+i{%$WVSkhUMM0-rIDo4s27TZ0^mRZ$)jTggZXwF z43kI4@d41%THh;1OKb4}@Olf$%q#)`YN-rrm!~`XGqcF+t>^*R&Y^eL`qDD$51)8r zwsROgKq6o83Yu@fqq+Ch8z=IGXaPj|$n^zpo?M$(mT@2eT3Yv|d%o$9>FE<1Yl3?5vEWjLHC_e2E8-`rWv{s3fB@Kq4PpfNj4QWL!L708u_|I(z04 z^Lh)lD_5muR05zYzI>xkU;X#I(c-EvP>*I2jli8<&z%3ZO^6$tmPtPDAbHpPQ&nTnd#@3P6-ke_RSJ?nYl) z4wW$!phrGUL-XLLG=TZ;N2rXU0HS>4<{jzcZpe+>!5^+L08u{U<+d^aeX$iNV;}&p zHIQWIkfhQ$vT$#po`Ki?;K;4JogdfiT;DZBfT91E54|xNdTZ9(oZ1mKr?!LCs`NFd jF2pXx)1IG#(4PMT9}n*>Os?Fo00000NkvXXu0mjfgeWVW literal 0 HcmV?d00001 diff --git a/augur/static/favicon/favicon.ico b/augur/static/favicon/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..c0f9decc1e0e8cbef8e25a1e2e19ec14f1ef3c9d GIT binary patch literal 15406 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 literal 0 HcmV?d00001 diff --git a/augur/static/favicon/favicon_source.svg b/augur/static/favicon/favicon_source.svg new file mode 100644 index 0000000000..140acc758a --- /dev/null +++ b/augur/static/favicon/favicon_source.svg @@ -0,0 +1,78 @@ + + + + + + + + image/svg+xml + + + + + + + + A + + diff --git a/augur/static/favicon/site.webmanifest b/augur/static/favicon/site.webmanifest new file mode 100644 index 0000000000..45dc8a2065 --- /dev/null +++ b/augur/static/favicon/site.webmanifest @@ -0,0 +1 @@ +{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} \ No newline at end of file diff --git a/augur/static/img/Chaoss_Logo.png b/augur/static/img/Chaoss_Logo.png new file mode 100644 index 0000000000000000000000000000000000000000..17da771dcf2d02607968c72f71ad47fa51323e7e GIT binary patch literal 19432 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 literal 0 HcmV?d00001 diff --git a/augur/static/img/auggie_shrug.png b/augur/static/img/auggie_shrug.png new file mode 100644 index 0000000000000000000000000000000000000000..f53cac1897f2dcc56bb375ffb4807303e613dfcb GIT binary patch literal 29646 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%0Aq(rzC3)1it=Y|MPRSEWh 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 L)z4*}Q$iB}WHa&O literal 0 HcmV?d00001 diff --git a/augur/static/img/augur_logo_black.png b/augur/static/img/augur_logo_black.png new file mode 100644 index 0000000000000000000000000000000000000000..5256f232e6a01e630a43e9a002ec94e6de915b26 GIT binary patch literal 42763 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 z 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 z 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�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 Sp!EvyyQ^uSQKV-7;(q`RmVed& literal 0 HcmV?d00001 diff --git a/augur/static/img/notification-icon.svg b/augur/static/img/notification-icon.svg new file mode 100644 index 0000000000..10946c98b3 --- /dev/null +++ b/augur/static/img/notification-icon.svg @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + diff --git a/augur/static/js/range.js b/augur/static/js/range.js new file mode 100644 index 0000000000..029e803104 --- /dev/null +++ b/augur/static/js/range.js @@ -0,0 +1,3 @@ +function range(size, startAt = 0) { + return [...Array(size).keys()].map(i => i + startAt); +} diff --git a/augur/static/js/sleep.js b/augur/static/js/sleep.js new file mode 100644 index 0000000000..535240241d --- /dev/null +++ b/augur/static/js/sleep.js @@ -0,0 +1,4 @@ +async function sleep(timeout) { + // sleep for timeout milliseconds + await new Promise(resolve => setTimeout(resolve, timeout)); +} diff --git a/augur/static/js/textarea_resize.js b/augur/static/js/textarea_resize.js new file mode 100644 index 0000000000..624d004f40 --- /dev/null +++ b/augur/static/js/textarea_resize.js @@ -0,0 +1,12 @@ +// Create auto-resizing for any textareas in the document +const tx = document.getElementsByTagName("textarea"); + +for (let i = 0; i < tx.length; i++) { + tx[i].setAttribute("style", "height:" + (tx[i].scrollHeight) + "px;overflow-y:hidden;"); + tx[i].addEventListener("input", OnTextAreaInput, false); +} + +function OnTextAreaInput() { + this.style.height = "auto"; + this.style.height = (this.scrollHeight) + "px"; +} diff --git a/augur/templates/admin-dashboard.html b/augur/templates/admin-dashboard.html new file mode 100644 index 0000000000..a24829c99f --- /dev/null +++ b/augur/templates/admin-dashboard.html @@ -0,0 +1,178 @@ + + + + + + + + + + + + + + + + + + + + + Dasboard - Augur View + + + + + +

+
+
+
+ Dashboard +
+
+ +
+ +
+ {# Start dashboard content #} +
+

Stats

+ {# Start content card #} +
+
+ {# Start form body #} +
+ {% for section in sections %} +
+
+
{{ section.title }}
+
+ {% for setting in section.settings %} +
+
+ + +
{{ setting.description or "No description available" }}
+
+
+ {% endfor %} +
+ {% endfor %} + {#
+
+ +
+
#} +
+
+
+

User Accounts

+ {# Start content card #} +
+
+
+ {% for section in sections %} +
+
+
{{ section.title }}
+
+ {% for setting in section.settings %} +
+
+ + +
{{ setting.description or "No description available" }}
+
+
+ {% endfor %} +
+ {% endfor %} + {#
+
+ +
+
#} +
+
+
+

Configuration

+ {# Start content card #} +
+
+
+ {% for section in config.items() %} +
+
+
{{ section[0] }}
+
+ {% for setting in section[1].items() %} +
+
+ + +
No description available
+
+
+ {% endfor %} +
+ {% endfor %} +
+
+ +
+
+
+
+
+
+
+
+ + + + diff --git a/augur/templates/first-time.html b/augur/templates/first-time.html new file mode 100644 index 0000000000..c8eb284da8 --- /dev/null +++ b/augur/templates/first-time.html @@ -0,0 +1,211 @@ +{# https://www.bootdey.com/snippets/view/dark-profile-settings #} + + + + + + + + + + + + + + +
+
+ {# Start sidebar #} +
+
+
+ +
+ +
+
+ {# Start form body #} +
+
+
+
+ {% for section in sections %} +
+
+
{{ section.title }}
+
+ {% for setting in section.settings %} +
+
+ + +
{{ setting.description }}
+
+
+ {% endfor %} +
+ {% endfor %} +
+
+
Gunicorn Settings
+
+
+
+
{{ gunicorn_placeholder }}
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+ + + + + + + diff --git a/augur/templates/groups-table.html b/augur/templates/groups-table.html new file mode 100644 index 0000000000..abe57a5774 --- /dev/null +++ b/augur/templates/groups-table.html @@ -0,0 +1,33 @@ +{% if groups %} +
+ + + + + + + + + + + + + {% for group in groups %} + + + + + + + + + + {% endfor %} + +
#Group NameGroup IDDescriptionLast ModifiedData Collection Date
{{loop.index}}{{ group.rg_name }}{{ group.repo_group_id }}{{ group.rg_description }}{{ group.rg_last_modified }}{{ group.data_collection_date }}TODO
+
+{% elif query_key %} +

Your search did not match any results

+{% else %} +

Unable to load group information

+{% endif %} diff --git a/augur/templates/index.html b/augur/templates/index.html new file mode 100644 index 0000000000..1049e4b777 --- /dev/null +++ b/augur/templates/index.html @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + {% if title %} + {{title}} - Augur View + {% else %} + Augur View + {% endif %} + + {% if redirect %} + + {% endif %} + + + + + + + {% include 'notifications.html' %} + + {% include 'navbar.html' %} + +
+ {% if invalid %} +

Invalid API URL

+

The API URL [{{ api_url or 'unspecified'}}] is invalid

+ {% elif body %} + {% include '%s.html' % body ignore missing %} + {% else %} +

404 - Page Not Found

+

The page you were looking for isn't here, try clicking one of the navigation links above

+ {% endif %} +
+ + + + diff --git a/augur/templates/loading.html b/augur/templates/loading.html new file mode 100644 index 0000000000..052af79eab --- /dev/null +++ b/augur/templates/loading.html @@ -0,0 +1,14 @@ +{% if not d %} +

Uh oh, Something went wrong!

+

You were sent to this page because we were loading something for you, but we didn't catch your destination.

+

Go back to the previous page and try again. If that doesn't help, submit an issue to https://github.com/chaoss/augur .

+{% else %} + +

Give us a moment!

+

We are retreiving some data for you, and it may take up to a few seconds to load.

+

If you aren't redirected in a few seconds, go back to the previous page and try again.

+ +

Redirecting to: {{url_for('root', path=d)}}

+{% endif %} diff --git a/augur/templates/login.html b/augur/templates/login.html new file mode 100644 index 0000000000..72744d780a --- /dev/null +++ b/augur/templates/login.html @@ -0,0 +1,157 @@ +
+
+ +
+
+ + + + \ No newline at end of file diff --git a/augur/templates/navbar.html b/augur/templates/navbar.html new file mode 100644 index 0000000000..e84c639a9b --- /dev/null +++ b/augur/templates/navbar.html @@ -0,0 +1,67 @@ + diff --git a/augur/templates/notice.html b/augur/templates/notice.html new file mode 100644 index 0000000000..46ed7ead66 --- /dev/null +++ b/augur/templates/notice.html @@ -0,0 +1,6 @@ +{% if messageTitle %} +

{{messageTitle}}

+{% endif %} +{% if messageBody %} +

{{messageBody}}

+{% endif %} diff --git a/augur/templates/notifications.html b/augur/templates/notifications.html new file mode 100644 index 0000000000..b59c673391 --- /dev/null +++ b/augur/templates/notifications.html @@ -0,0 +1,79 @@ +{% with messages = get_flashed_messages() %} + +
+ {% if messages %} + {% for message in messages %} + + {% endfor %} + {% endif %} +
+ + + + +{% endwith %} + diff --git a/augur/templates/repo-commits.html b/augur/templates/repo-commits.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/augur/templates/repo-info.html b/augur/templates/repo-info.html new file mode 100644 index 0000000000..e5aa225ec9 --- /dev/null +++ b/augur/templates/repo-info.html @@ -0,0 +1,128 @@ + + +
+
+ {% if repo.repo_id %} +

Report for: {{ repo.repo_name|title }}

+

https://{{ repo.url }}

+ {% for report in reports %} +

{{ report|replace("_", " ")|title }}

+ {% for image in images[report] %} +
+
+
+
+
+ +
+
+ {% endfor %} + {% endfor %} + {% else %} +

Repository {{ repo_id }} not found

+ {% endif %} +

+
+{% if repo.repo_id %} +{# Wait for cache response: + This method queries the server from the client, asking for confirmation + of which images are available on the server. The server will asynchronously + download the requested images as the page is loading, then once the page + loads, the client will query a locking endpoint on the server and wait + for a response. +#} + +{% endif %} + + + + + + diff --git a/augur/templates/repos-card.html b/augur/templates/repos-card.html new file mode 100644 index 0000000000..9bd6cc4f38 --- /dev/null +++ b/augur/templates/repos-card.html @@ -0,0 +1,27 @@ +{% if repos %} +
+
+ {% for repo in repos %} +
+
+
+
+
{{ repo.repo_name }}
+

Repository Status: {{ repo.repo_status }}

+

All Time Commits: {{ repo.commits_all_time|int }}

+

All Time Issues: {{ repo.issues_all_time|int }}

+
+ +
+
+
+ {% endfor %} +
+
+{% elif query_key %} +

Your search did not match any repositories

+{% else %} +

Unable to load repository information

+{% endif %} diff --git a/augur/templates/repos-table.html b/augur/templates/repos-table.html new file mode 100644 index 0000000000..96e6563b25 --- /dev/null +++ b/augur/templates/repos-table.html @@ -0,0 +1,92 @@ +{% if repos %} + + + +{# Create the header row for the repo table: + Here we dynamically generate the header row by defining a dictionary list + which contains the titles of each column, accompanied by an optional "key" + item. If a column definition contains a "key" item, that column is assumed + to be sortable, sorting links for that data are generated using the given + key. It is done this way because the client does not receive the full data + each time they load the page, and instead the server sorts the full data. +#} +{%- set tableHeaders = + [{"title" : "#"}, + {"title" : "Repo Name", "key" : "repo_name"}, + {"title" : "Group", "key" : "rg_name"}, + {"title" : "Reports"}, + {"title" : "Commits", "key" : "commits_all_time"}, + {"title" : "Issues", "key" : "issues_all_time"}, + {"title" : "Change Requests"}] -%} +
+ + + + + {%- for header in tableHeaders -%} + {% if header.key %} + {%- if sorting == header.key -%} + {%- set sorting_link = url_for(PS, q=query_key, p=activePage, s=header.key, r= not reverse) -%} + {%- else -%} + {%- set sorting_link = url_for(PS, q=query_key, p=activePage, s=header.key) -%} + {%- endif -%} + + {% else -%} + + {% endif %} {%- endfor -%} + + + + + {% for repo in repos %} + + + + + + + + + + {% endfor %} + +
{{ header.title }} + {%- if sorting == header.key and reverse %} ▲ {% elif sorting == header.key %} ▼ {% endif %}{{ header.title }}
{{loop.index + (activePage - 1) * offset}}{{ repo.repo_name }}{{ repo.rg_name }}TODO{{ repo.commits_all_time|int }}{{ repo.issues_all_time|int }}TODO
+
+ +
+ +{% elif query_key %} +

Your search did not match any repositories

+{% elif current_user.is_authenticated %} +

No Repos Tracked

+

Add repos to your personal tracker in your profile page

+{% else %} +

Unable to load repository information

+{% endif %} diff --git a/augur/templates/settings.html b/augur/templates/settings.html new file mode 100644 index 0000000000..9a6052fe02 --- /dev/null +++ b/augur/templates/settings.html @@ -0,0 +1,139 @@ +
+
+
+
+
+

{{ current_user.id }}

+ Delete Account +
+ +
+
+
+

Update Password

+ +
+
+
+
+
+

Your Repo Groups

+ {%- set groups = current_user.get_groups() -%} + {% if groups %} + {% for group in groups %} + {%- set tableHeaders = + [{"title" : "Group ID"}, + {"title" : "Group Name"}] + -%} +
+ + + + + {%- for header in tableHeaders -%} + + {%- endfor -%} + + + + + {% for repo in repos %} + + + + + {% endfor %} + +
{{ header.title }}
{{ group.group_id }}{{ group.name }}
+
+ {% else %} +

No repos selected

+ {% endif %} +
+
+
+

Add Repos

+ +
+
+
+ + + \ No newline at end of file diff --git a/augur/templates/status.html b/augur/templates/status.html new file mode 100644 index 0000000000..80a9ff05c9 --- /dev/null +++ b/augur/templates/status.html @@ -0,0 +1,233 @@ + + + +

Collection Status

+
+
+
+

Pull Requests

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

Issues

+
+
+
+
+
+
+
+

Time between most recently collected issue and last collection run

+
+
+
+
+
+
+
+

Commits

+
+
+
+
+
+
+
+ +
+
+ + diff --git a/augur/templates/toasts.html b/augur/templates/toasts.html new file mode 100644 index 0000000000..cf707754f2 --- /dev/null +++ b/augur/templates/toasts.html @@ -0,0 +1,60 @@ +{% with messages = get_flashed_messages() %} +
+ {% if messages %} + {% for message in messages %} + + {% endfor %} + {% endif %} +
+ +{% endwith %} + + + + diff --git a/setup.py b/setup.py index 80891ead96..75f424036d 100644 --- a/setup.py +++ b/setup.py @@ -39,7 +39,7 @@ "Beaker==1.11.0", # 1.11.0 "SQLAlchemy==1.3.23", # 1.4.40 "itsdangerous==2.0.1", # 2.1.2 - "Jinja2==3.0.2", # 3.1.2 + 'Jinja2~=3.0.3', "Flask==2.0.2", # 2.2.2 "Flask-Cors==3.0.10", "Flask-Login==0.5.0", @@ -77,6 +77,7 @@ "eventlet==0.33.1", "flower==1.2.0", "tornado==6.1", # added because it sometimes errors when tornado is not 6.1 even though nothing we install depends on it + 'Werkzeug~=2.0.0', "pylint==2.15.5" ], extras_require={ From 95702ed5325c47793881bf3978861d6cf4c65206 Mon Sep 17 00:00:00 2001 From: Andrew Brain Date: Thu, 5 Jan 2023 19:56:05 -0600 Subject: [PATCH 044/150] Convert augur view login logic to the user orm model Signed-off-by: Andrew Brain --- augur/api/view/augur_view.py | 11 +- augur/api/view/routes.py | 39 ++++-- .../application/db/models/augur_operations.py | 118 ++++++++++++++++++ 3 files changed, 156 insertions(+), 12 deletions(-) diff --git a/augur/api/view/augur_view.py b/augur/api/view/augur_view.py index 5eb5cf55df..ff25847906 100644 --- a/augur/api/view/augur_view.py +++ b/augur/api/view/augur_view.py @@ -2,7 +2,9 @@ from flask_login import LoginManager from .utils import * from .url_converters import * -from .server import User +# from .server import User +from augur.application.db.models import User +from augur.application.db.session import DatabaseSession login_manager = LoginManager() @@ -31,14 +33,17 @@ def unauthorized(): @login_manager.user_loader def load_user(user_id): - user = User(user_id) - if not user.exists: + user = User.get_user(user_id) + + if not user: return None # The flask_login library sets a unique session["_id"] # when login_user() is called successfully if session.get("_id") is not None: + + # TODO: Add these as properties user._is_authenticated = True user._is_active = True diff --git a/augur/api/view/routes.py b/augur/api/view/routes.py index 5fe14b94e3..3c7281114a 100644 --- a/augur/api/view/routes.py +++ b/augur/api/view/routes.py @@ -1,8 +1,15 @@ +import logging from flask import Flask, render_template, render_template_string, request, abort, jsonify, redirect, url_for, session, flash +from sqlalchemy.orm.exc import NoResultFound from .utils import * from flask_login import login_user, logout_user, current_user, login_required -from .server import User +# from .server import User +from augur.application.db.models import User from .server import LoginException +from augur.application.db.session import DatabaseSession + +logger = logging.getLogger(__name__) + # ROUTES ----------------------------------------------------------------------- @@ -123,23 +130,37 @@ def status_view(): def user_login(): if request.method == 'POST': try: - user_id = request.form.get('username') + username = request.form.get('username') remember = request.form.get('remember') is not None - if user_id is None: + password = request.form.get('password') + + if username is None: raise LoginException("A login issue occurred") - user = User(user_id) + # test if the user does not exist then the login is invalid + user = User.get_user(username) + if not user and not request.form.get('register'): + raise LoginException("Invalid login credentials") + # register a user if request.form.get('register') is not None: - if user.exists: + if user: raise LoginException("User already exists") - if not user.register(request): + + email = request.form.get('email') + first_name = request.form.get('first_name') + last_name = request.form.get('last_name') + admin = request.form.get('admin') + + result = User.create_user(username, password, email, first_name, last_name, admin) + if "Error" in result.keys(): raise LoginException("An error occurred registering your account") else: - flash("Account successfully created") + flash(result["status"]) - if user.validate(request) and login_user(user, remember = remember): - flash(f"Welcome, {user_id}!") + # Log the user in if the password is valid + if User.validate(password) and login_user(user, remember = remember): + flash(f"Welcome, {username}!") if "login_next" in session: return redirect(session.pop("login_next")) return redirect(url_for('root')) diff --git a/augur/application/db/models/augur_operations.py b/augur/application/db/models/augur_operations.py index 4ddb30b5fb..e08b961607 100644 --- a/augur/application/db/models/augur_operations.py +++ b/augur/application/db/models/augur_operations.py @@ -1,6 +1,12 @@ # coding: utf-8 from sqlalchemy import BigInteger, SmallInteger, Column, Index, Integer, String, Table, text, UniqueConstraint, Boolean, ForeignKey from sqlalchemy.dialects.postgresql import TIMESTAMP, UUID +from sqlalchemy.orm.exc import NoResultFound +from werkzeug.security import generate_password_hash, check_password_hash +import logging + +logger = logging.getLogger(__name__) + from augur.application.db.models.base import Base @@ -171,6 +177,7 @@ class Config(Base): # add admit column to database class User(Base): + user_id = Column(Integer, primary_key=True) login_name = Column(String, nullable=False) login_hashword = Column(String, nullable=False) @@ -195,6 +202,117 @@ class User(Base): groups = relationship("UserGroup") tokens = relationship("UserSessionToken") + _is_authenticated = False + _is_active = False + _is_anoymous = True + + @property + def is_authenticated(self): + return self._is_authenticated + + @is_authenticated.setter + def is_authenticated(self, val): + self._is_authenticated = val + + @property + def is_active(self): + return self._is_active + + @is_active.setter + def is_active(self, val): + self._is_active = val + + @property + def is_anoymous(self): + return self._is_anoymous + + @is_anoymous.setter + def is_anoymous(self, val): + self._is_anoymous = val + + @staticmethod + def exists(username): + return User.get_user(username) is not None + + def validate(self, password) -> bool: + + from augur.application.db.session import DatabaseSession + + if not password: + return False + + return check_password_hash(self.login_hashword, password) + + @staticmethod + def get_user(username: str): + + from augur.application.db.session import DatabaseSession + + with DatabaseSession(logger) as session: + try: + return session.query(User).filter(User.login_name == username).one() + except NoResultFound: + return None + + @staticmethod + def create_user(username: str, password: str, email: str, first_name:str, last_name:str, admin=False): + + from augur.application.db.session import DatabaseSession + + if username is None or password is None or email is None or first_name is None or last_name is None: + return {"status": "Missing field"} + + with DatabaseSession(logger) as session: + + user = session.query(User).filter(User.login_name == username).first() + if user is not None: + return {"status": "A User already exists with that username"} + + emailCheck = session.query(User).filter(User.email == email).first() + if emailCheck is not None: + return {"status": "A User already exists with that email"} + + try: + user = User(login_name = username, login_hashword = generate_password_hash(password), email = email, first_name = first_name, last_name = last_name, tool_source="User API", tool_version=None, data_source="API", admin=admin) + session.add(user) + session.commit() + return {"status": "Account successfully created"} + except AssertionError as exception_message: + return {"Error": f"{exception_message}."} + + @staticmethod + def delete_user(): + + from augur.application.db.session import DatabaseSession + + def update_user(): + pass + + def add_group(): + + pass + + def remove_group(): + pass + + def add_repo(): + pass + + def remove_repo(): + pass + + def add_org(): + pass + + def get_groups(): + pass + + def get_group_repos(): + pass + + def get_group_repo_count(): + pass + class UserGroup(Base): group_id = Column(BigInteger, primary_key=True) From 626c15f930317546b9864dd6da9106642082f7d1 Mon Sep 17 00:00:00 2001 From: Andrew Brain <61482022+ABrain7710@users.noreply.github.com> Date: Fri, 6 Jan 2023 09:31:19 -0600 Subject: [PATCH 045/150] Outline user methods on the user orm class Signed-off-by: Andrew Brain <61482022+ABrain7710@users.noreply.github.com> --- .../application/db/models/augur_operations.py | 199 ++++++++++++++++-- 1 file changed, 182 insertions(+), 17 deletions(-) diff --git a/augur/application/db/models/augur_operations.py b/augur/application/db/models/augur_operations.py index e08b961607..cef0cceccd 100644 --- a/augur/application/db/models/augur_operations.py +++ b/augur/application/db/models/augur_operations.py @@ -285,33 +285,198 @@ def delete_user(): from augur.application.db.session import DatabaseSession - def update_user(): + def update_user(self): + + from augur.application.db.session import DatabaseSession pass - def add_group(): + def add_group(self, group_name): - pass + from augur.tasks.github.util.github_task_session import GithubTaskSession + from augur.util.repo_load_controller import RepoLoadController - def remove_group(): - pass + if group_name == "default": + return {"status": "Reserved Group Name"} + + with GithubTaskSession(logger) as session: - def add_repo(): - pass + repo_load_controller = RepoLoadController(gh_session=session) - def remove_repo(): - pass + result = repo_load_controller.add_user_group(self.user_id, group_name) - def add_org(): - pass + return result - def get_groups(): - pass + def remove_group(self, group_name): - def get_group_repos(): - pass + from augur.tasks.github.util.github_task_session import GithubTaskSession + from augur.util.repo_load_controller import RepoLoadController + + with GithubTaskSession(logger) as session: - def get_group_repo_count(): - pass + repo_load_controller = RepoLoadController(gh_session=session) + + result = repo_load_controller.remove_user_group(self.user_id, group_name) + + return result + + def add_repo(self, group_name, repo_url): + + from augur.tasks.github.util.github_task_session import GithubTaskSession + from augur.util.repo_load_controller import RepoLoadController + + with GithubTaskSession(logger) as session: + + repo_load_controller = RepoLoadController(gh_session=session) + + result = repo_load_controller.add_frontend_repo(repo_url, self.user_id, group_name) + + return result + + def remove_repo(self, group_name, repo_id): + + from augur.tasks.github.util.github_task_session import GithubTaskSession + from augur.util.repo_load_controller import RepoLoadController + + with GithubTaskSession(logger) as session: + + repo_load_controller = RepoLoadController(gh_session=session) + + result = repo_load_controller.remove_frontend_repo(repo_id, self.user_id, group_name) + + return result + + def add_org(self, group_name, org_url): + + from augur.tasks.github.util.github_task_session import GithubTaskSession + from augur.util.repo_load_controller import RepoLoadController + + with GithubTaskSession(logger) as session: + + repo_load_controller = RepoLoadController(gh_session=session) + + result = repo_load_controller.add_frontend_org(org, user.user_id, group_name) + + return result + + def get_groups(self): + + from augur.tasks.github.util.github_task_session import GithubTaskSession + from augur.util.repo_load_controller import RepoLoadController + + with GithubTaskSession(logger) as session: + + controller = RepoLoadController(session) + + user_groups = controller.get_user_groups(user.user_id) + + return {"groups": user_groups} + + def get_group_names(self): + + user_groups = self.get_groups()["groups"] + + group_names = [group.name for group in user_groups] + + return {"group_names": group_names} + + + + def get_group_repos(self, group_name, page=0, page_size=25, sort="repo_id", direction="ASC"): + + from augur.tasks.github.util.github_task_session import GithubTaskSession + from augur.util.repo_load_controller import RepoLoadController + + if not group_name: + return {"status": "Missing argument"} + + if direction and direction != "ASC" and direction != "DESC": + return {"status": "Invalid direction"} + + try: + page = int(page) + page_size = int(page_size) + except ValueError: + return {"status": "Page size and page should be integers"} + + if page < 0 or page_size < 0: + return {"status": "Page size and page should be postive"} + + + with GithubTaskSession(logger) as session: + + controller = RepoLoadController(session) + + group_id = controller.convert_group_name_to_id(user.user_id, group_name) + if group_id is None: + return {"status": "Group does not exist"} + + + order_by = sort if sort else "repo_id" + order_direction = direction if direction else "ASC" + + get_page_of_repos_sql = text(f""" + SELECT + augur_data.repo.repo_id, + augur_data.repo.repo_name, + augur_data.repo.description, + augur_data.repo.repo_git AS url, + augur_data.repo.repo_status, + a.commits_all_time, + b.issues_all_time, + rg_name, + augur_data.repo.repo_group_id + FROM + augur_data.repo + LEFT OUTER JOIN augur_data.api_get_all_repos_commits a ON augur_data.repo.repo_id = a.repo_id + LEFT OUTER JOIN augur_data.api_get_all_repos_issues b ON augur_data.repo.repo_id = b.repo_id + JOIN augur_operations.user_repos ON augur_data.repo.repo_id = augur_operations.user_repos.repo_id + JOIN augur_data.repo_groups ON augur_data.repo.repo_group_id = augur_data.repo_groups.repo_group_id + WHERE augur_operations.user_repos.group_id = {group_id} + ORDER BY {order_by} {order_direction} + LIMIT {page_size} + OFFSET {page*page_size}; + """) + + results = pd.read_sql(get_page_of_repos_sql, create_database_engine()) + results['url'] = results['url'].apply(lambda datum: datum.split('//')[1]) + + b64_urls = [] + for i in results.index: + b64_urls.append(base64.b64encode((results.at[i, 'url']).encode())) + results['base64_url'] = b64_urls + + data = results.to_json(orient="records", date_format='iso', date_unit='ms') + + return {"status": "success", "data": data} + + def get_group_repo_count(self, group_name): + + from augur.tasks.github.util.github_task_session import GithubTaskSession + from augur.util.repo_load_controller import RepoLoadController + + with GithubTaskSession(logger) as session: + + controller = RepoLoadController(session) + + group_id = controller.convert_group_name_to_id(user.user_id, group_name) + if group_id is None: + return {"status": "Group does not exist"} + + get_page_of_repos_sql = text(f""" + SELECT + count(*) + FROM + augur_data.repo + LEFT OUTER JOIN augur_data.api_get_all_repos_commits a ON augur_data.repo.repo_id = a.repo_id + LEFT OUTER JOIN augur_data.api_get_all_repos_issues b ON augur_data.repo.repo_id = b.repo_id + JOIN augur_operations.user_repos ON augur_data.repo.repo_id = augur_operations.user_repos.repo_id + JOIN augur_data.repo_groups ON augur_data.repo.repo_group_id = augur_data.repo_groups.repo_group_id + WHERE augur_operations.user_repos.group_id = {group_id} + """) + + result = session.fetchall_data_from_sql_text(get_page_of_repos_sql) + + return {"repos": result[0]["count"]} class UserGroup(Base): From dd9eed0c84282a40b5f00d25aacc96113e2d1980 Mon Sep 17 00:00:00 2001 From: Andrew Brain <61482022+ABrain7710@users.noreply.github.com> Date: Fri, 6 Jan 2023 09:49:29 -0600 Subject: [PATCH 046/150] Update routes to use orm functions Signed-off-by: Andrew Brain <61482022+ABrain7710@users.noreply.github.com> --- augur/api/routes/user.py | 228 ++++++++------------------------------- 1 file changed, 42 insertions(+), 186 deletions(-) diff --git a/augur/api/routes/user.py b/augur/api/routes/user.py index e14cd8780d..afe3919139 100644 --- a/augur/api/routes/user.py +++ b/augur/api/routes/user.py @@ -10,6 +10,7 @@ import base64 import pandas as pd from flask import request, Response, jsonify +from flask_login import login_user, logout_user, current_user, login_required from werkzeug.security import generate_password_hash, check_password_hash from sqlalchemy.sql import text from sqlalchemy.orm import sessionmaker @@ -199,12 +200,20 @@ def query_user(user): return jsonify({"status": True}) + @server.app.route(f"/{AUGUR_API_VERSION}/user/test", methods=['POST']) + def test(): + if not development and not request.is_secure: + return generate_upgrade_request() + + test = request.args.get("test") + + return jsonify({"result": test}) + @server.app.route(f"/{AUGUR_API_VERSION}/user/create", methods=['POST']) def create_user(): if not development and not request.is_secure: return generate_upgrade_request() - session = Session() username = request.args.get("username") password = request.args.get("password") email = request.args.get("email") @@ -212,26 +221,13 @@ def create_user(): last_name = request.args.get("last_name") admin = request.args.get("create_admin") or False - if username is None or password is None or email is None: - # https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/400 - return jsonify({"status": "Missing argument"}), 400 - user = session.query(User).filter(User.login_name == username).first() - if user is not None: - return jsonify({"status": "User already exists"}) - emailCheck = session.query(User).filter(User.email == email).first() - if emailCheck is not None: - return jsonify({"status": "Email already exists"}) - try: - user = User(login_name = username, login_hashword = generate_password_hash(password), email = email, first_name = first_name, last_name = last_name, tool_source="User API", tool_version=None, data_source="API", admin=False) - session.add(user) - session.commit() - return jsonify({"status": "User created"}) - except AssertionError as exception_message: - return jsonify(msg='Error: {}. '.format(exception_message)), 400 + result = User.create_user(username, password, email, first_name, last_name, admin) + + return jsonify(result) + @server.app.route(f"/{AUGUR_API_VERSION}/user/remove", methods=['POST', 'DELETE']) - @api_key_required - @user_login_required + @login_required def delete_user(user): if not development and not request.is_secure: return generate_upgrade_request() @@ -251,8 +247,7 @@ def delete_user(user): return jsonify({"status": "User deleted"}), 200 @server.app.route(f"/{AUGUR_API_VERSION}/user/update", methods=['POST']) - @api_key_required - @user_login_required + @login_required def update_user(user): if not development and not request.is_secure: return generate_upgrade_request() @@ -289,8 +284,7 @@ def update_user(user): @server.app.route(f"/{AUGUR_API_VERSION}/user/add_repo", methods=['GET', 'POST']) - @api_key_required - @user_login_required + @login_required def add_user_repo(user): if not development and not request.is_secure: return generate_upgrade_request() @@ -298,40 +292,22 @@ def add_user_repo(user): repo = request.args.get("repo_url") group_name = request.args.get("group_name") - with GithubTaskSession(logger) as session: - - if repo is None or group_name is None: - return jsonify({"status": "Missing argument"}), 400 + result = user.add_repo(group_name, repo) - repo_load_controller = RepoLoadController(gh_session=session) - - result = repo_load_controller.add_frontend_repo(repo, user.user_id, group_name) - - return jsonify(result) + return jsonify(result) @server.app.route(f"/{AUGUR_API_VERSION}/user/add_group", methods=['GET', 'POST']) - @api_key_required - @user_login_required + @login_required def add_user_group(user): if not development and not request.is_secure: return generate_upgrade_request() group_name = request.args.get("group_name") - if group_name == "default": - return jsonify({"status": "Reserved Group Name"}) - - with GithubTaskSession(logger) as session: + result = user.add_group(group_name) - if group_name is None: - return jsonify({"status": "Missing argument"}), 400 - - repo_load_controller = RepoLoadController(gh_session=session) - - result = repo_load_controller.add_user_group(user.user_id, group_name) - - return jsonify(result) + return jsonify(result) @server.app.route(f"/{AUGUR_API_VERSION}/user/remove_group", methods=['GET', 'POST']) @api_key_required @@ -341,24 +317,14 @@ def remove_user_group(user): return generate_upgrade_request() group_name = request.args.get("group_name") - - with GithubTaskSession(logger) as session: - - if group_name is None: - return jsonify({"status": "Missing argument"}), 400 - repo_load_controller = RepoLoadController(gh_session=session) + result = user.remove_group(group_name) - result = repo_load_controller.remove_user_group(user.user_id, group_name) - - return jsonify(result) - - + return jsonify(result) @server.app.route(f"/{AUGUR_API_VERSION}/user/add_org", methods=['GET', 'POST']) - @api_key_required - @user_login_required + @login_required def add_user_org(user): if not development and not request.is_secure: return generate_upgrade_request() @@ -366,48 +332,28 @@ def add_user_org(user): org = request.args.get("org_url") group_name = request.args.get("group_name") - with GithubTaskSession(logger) as session: - - if org is None or group_name is None: - return jsonify({"status": "Missing argument"}), 400 - - repo_load_controller = RepoLoadController(gh_session=session) + result = user.add_org(group_name, org_url) - result = repo_load_controller.add_frontend_org(org, user.user_id, group_name) - - return jsonify(result) + return jsonify(result) @server.app.route(f"/{AUGUR_API_VERSION}/user/remove_repo", methods=['GET', 'POST']) - @api_key_required - @user_login_required + @login_required def remove_user_repo(user): if not development and not request.is_secure: return generate_upgrade_request() - try: - repo_id = int(request.args.get("repo_id")) - except ValueError: - return {"status": "repo_id must be an integer"} - group_name = request.args.get("group_name") + group_name = request.args.get("group_name") + repo_id = request.args.get("repo_id") - with GithubTaskSession(logger) as session: - - if repo_id is None or group_name is None: - return jsonify({"status": "Missing argument"}), 400 - - repo_load_controller = RepoLoadController(gh_session=session) - - result = repo_load_controller.remove_frontend_repo(repo_id, user.user_id, group_name) - - return jsonify(result) + result = user.remove_repo(group_name, repo_id) + return jsonify(result) @server.app.route(f"/{AUGUR_API_VERSION}/user/group_repos", methods=['GET', 'POST']) - @api_key_required - @user_login_required + @login_required def group_repos(user): """Select repos from a user group by name @@ -436,76 +382,14 @@ def group_repos(user): return generate_upgrade_request() group_name = request.args.get("group_name") - - # Set default values for ancillary arguments page = request.args.get("page") or 0 page_size = request.args.get("page_size") or 25 - sort = request.args.get("sort") - direction = request.args.get("direction") or ("ASC" if sort else None) - - if not group_name: - return jsonify({"status": "Missing argument"}), 400 - - if direction and direction != "ASC" and direction != "DESC": - return jsonify({"status": "Invalid direction"}), 400 + sort = request.args.get("sort") or "repo_id" + direction = request.args.get("direction") or "ASC" - try: - page = int(page) - page_size = int(page_size) - except ValueError: - return jsonify({"status": "Page size and page should be integers"}), 400 + result = user.get_group_repos(group_name, page, page_size, sort, direction) - if page < 0 or page_size < 0: - return jsonify({"status": "Page size and page should be postive"}), 400 - - - with DatabaseSession(logger) as session: - - controller = RepoLoadController(session) - - group_id = controller.convert_group_name_to_id(user.user_id, group_name) - if group_id is None: - return jsonify({"status": "Group does not exist"}), 400 - - - order_by = sort if sort else "repo_id" - order_direction = direction if direction else "ASC" - - get_page_of_repos_sql = text(f""" - SELECT - augur_data.repo.repo_id, - augur_data.repo.repo_name, - augur_data.repo.description, - augur_data.repo.repo_git AS url, - augur_data.repo.repo_status, - a.commits_all_time, - b.issues_all_time, - rg_name, - augur_data.repo.repo_group_id - FROM - augur_data.repo - LEFT OUTER JOIN augur_data.api_get_all_repos_commits a ON augur_data.repo.repo_id = a.repo_id - LEFT OUTER JOIN augur_data.api_get_all_repos_issues b ON augur_data.repo.repo_id = b.repo_id - JOIN augur_operations.user_repos ON augur_data.repo.repo_id = augur_operations.user_repos.repo_id - JOIN augur_data.repo_groups ON augur_data.repo.repo_group_id = augur_data.repo_groups.repo_group_id - WHERE augur_operations.user_repos.group_id = {group_id} - ORDER BY {order_by} {order_direction} - LIMIT {page_size} - OFFSET {page*page_size}; - """) - - results = pd.read_sql(get_page_of_repos_sql, create_database_engine()) - results['url'] = results['url'].apply(lambda datum: datum.split('//')[1]) - - b64_urls = [] - for i in results.index: - b64_urls.append(base64.b64encode((results.at[i, 'url']).encode())) - results['base64_url'] = b64_urls - - data = results.to_json(orient="records", date_format='iso', date_unit='ms') - return Response(response=data, - status=200, - mimetype="application/json") + return jsonify(result) @@ -533,32 +417,9 @@ def group_repo_count(user): group_name = request.args.get("group_name") - if not group_name: - return jsonify({"status": "Missing argument"}), 400 - - with DatabaseSession(logger) as session: - - controller = RepoLoadController(session) - - group_id = controller.convert_group_name_to_id(user.user_id, group_name) - if group_id is None: - return jsonify({"status": "Group does not exist"}), 400 - - get_page_of_repos_sql = text(f""" - SELECT - count(*) - FROM - augur_data.repo - LEFT OUTER JOIN augur_data.api_get_all_repos_commits a ON augur_data.repo.repo_id = a.repo_id - LEFT OUTER JOIN augur_data.api_get_all_repos_issues b ON augur_data.repo.repo_id = b.repo_id - JOIN augur_operations.user_repos ON augur_data.repo.repo_id = augur_operations.user_repos.repo_id - JOIN augur_data.repo_groups ON augur_data.repo.repo_group_id = augur_data.repo_groups.repo_group_id - WHERE augur_operations.user_repos.group_id = {group_id} - """) - - result = session.fetchall_data_from_sql_text(get_page_of_repos_sql) - - return jsonify({"repos": result[0]["count"]}), 200 + result = user.group_repo_count(group_name) + + return jsonify(result) @server.app.route(f"/{AUGUR_API_VERSION}/user/groups", methods=['GET', 'POST']) @@ -581,13 +442,8 @@ def get_user_groups(user): if not development and not request.is_secure: return generate_upgrade_request() - with DatabaseSession(logger) as session: - - controller = RepoLoadController(session) - - user_groups = controller.get_user_groups(user.user_id) + result = user.get_groups() - group_names = [group.name for group in user_groups] + return jsonify(result) - return jsonify({"group_names": group_names}), 200 From 4e80daf968aba698836c45b9192d05fa6af062e2 Mon Sep 17 00:00:00 2001 From: Andrew Brain Date: Fri, 6 Jan 2023 11:33:56 -0600 Subject: [PATCH 047/150] Fix some login bugs Signed-off-by: Andrew Brain --- augur/api/routes/user.py | 28 ++++--------------- augur/api/view/augur_view.py | 6 +++- augur/api/view/routes.py | 13 +++++++-- .../application/db/models/augur_operations.py | 16 +++++++++-- 4 files changed, 34 insertions(+), 29 deletions(-) diff --git a/augur/api/routes/user.py b/augur/api/routes/user.py index afe3919139..449f98b4ca 100644 --- a/augur/api/routes/user.py +++ b/augur/api/routes/user.py @@ -137,12 +137,10 @@ def validate_user(): return jsonify({"status": "Invalid username"}) if checkPassword == False: return jsonify({"status": "Invalid password"}) - - # TODO Generate user session token to be stored in client browser - token = "USER SESSION TOKEN" + login_user(user) - return jsonify({"status": "Validated", "session": token}) + return jsonify({"status": "Validated"}) @server.app.route(f"/{AUGUR_API_VERSION}/user/oauth", methods=['POST']) def oauth_validate(): @@ -182,8 +180,6 @@ def generate_session(): return jsonify({"status": "Validated", "username": user, "session": token}) @server.app.route(f"/{AUGUR_API_VERSION}/user/query", methods=['POST']) - @api_key_required - @user_login_required def query_user(user): if not development and not request.is_secure: return generate_upgrade_request() @@ -200,16 +196,7 @@ def query_user(user): return jsonify({"status": True}) - @server.app.route(f"/{AUGUR_API_VERSION}/user/test", methods=['POST']) - def test(): - if not development and not request.is_secure: - return generate_upgrade_request() - - test = request.args.get("test") - - return jsonify({"result": test}) - - @server.app.route(f"/{AUGUR_API_VERSION}/user/create", methods=['POST']) + @server.app.route(f"/{AUGUR_API_VERSION}/user/create", methods=['GET', 'POST']) def create_user(): if not development and not request.is_secure: return generate_upgrade_request() @@ -310,8 +297,7 @@ def add_user_group(user): return jsonify(result) @server.app.route(f"/{AUGUR_API_VERSION}/user/remove_group", methods=['GET', 'POST']) - @api_key_required - @user_login_required + @login_required def remove_user_group(user): if not development and not request.is_secure: return generate_upgrade_request() @@ -394,8 +380,7 @@ def group_repos(user): @server.app.route(f"/{AUGUR_API_VERSION}/user/group_repo_count", methods=['GET', 'POST']) - @api_key_required - @user_login_required + @login_required def group_repo_count(user): """Count repos from a user group by name @@ -423,8 +408,7 @@ def group_repo_count(user): @server.app.route(f"/{AUGUR_API_VERSION}/user/groups", methods=['GET', 'POST']) - @api_key_required - @user_login_required + @login_required def get_user_groups(user): """Get a list of user groups by username diff --git a/augur/api/view/augur_view.py b/augur/api/view/augur_view.py index ff25847906..2347791d68 100644 --- a/augur/api/view/augur_view.py +++ b/augur/api/view/augur_view.py @@ -9,6 +9,7 @@ login_manager = LoginManager() def create_routes(server): + login_manager.init_app(server.app) server.app.secret_key = getSetting("session_key") @@ -34,15 +35,18 @@ def unauthorized(): @login_manager.user_loader def load_user(user_id): + print("Loading user") + user = User.get_user(user_id) if not user: + print("User not found") return None # The flask_login library sets a unique session["_id"] # when login_user() is called successfully if session.get("_id") is not None: - + # TODO: Add these as properties user._is_authenticated = True user._is_active = True diff --git a/augur/api/view/routes.py b/augur/api/view/routes.py index 3c7281114a..ef2e635ede 100644 --- a/augur/api/view/routes.py +++ b/augur/api/view/routes.py @@ -139,32 +139,39 @@ def user_login(): # test if the user does not exist then the login is invalid user = User.get_user(username) - if not user and not request.form.get('register'): + if not user and request.form.get('register'): raise LoginException("Invalid login credentials") # register a user if request.form.get('register') is not None: + print("Register user") if user: + print(f"User already exists: {user.__dict__}") raise LoginException("User already exists") email = request.form.get('email') first_name = request.form.get('first_name') last_name = request.form.get('last_name') - admin = request.form.get('admin') + admin = request.form.get('admin') or False result = User.create_user(username, password, email, first_name, last_name, admin) if "Error" in result.keys(): raise LoginException("An error occurred registering your account") else: + user = User.get_user(username) flash(result["status"]) # Log the user in if the password is valid - if User.validate(password) and login_user(user, remember = remember): + if user.validate(password): + + result = login_user(user, remember = remember) + print(result) flash(f"Welcome, {username}!") if "login_next" in session: return redirect(session.pop("login_next")) return redirect(url_for('root')) else: + print("Invalid login") raise LoginException("Invalid login credentials") except LoginException as e: flash(str(e)) diff --git a/augur/application/db/models/augur_operations.py b/augur/application/db/models/augur_operations.py index cef0cceccd..d9c7fcb15e 100644 --- a/augur/application/db/models/augur_operations.py +++ b/augur/application/db/models/augur_operations.py @@ -203,7 +203,7 @@ class User(Base): tokens = relationship("UserSessionToken") _is_authenticated = False - _is_active = False + _is_active = True _is_anoymous = True @property @@ -234,6 +234,9 @@ def is_anoymous(self, val): def exists(username): return User.get_user(username) is not None + def get_id(self): + return self.login_name + def validate(self, password) -> bool: from augur.application.db.session import DatabaseSession @@ -241,7 +244,10 @@ def validate(self, password) -> bool: if not password: return False - return check_password_hash(self.login_hashword, password) + result = check_password_hash(self.login_hashword, password) + print(f"Validating: {result}") + + return result @staticmethod def get_user(username: str): @@ -249,8 +255,11 @@ def get_user(username: str): from augur.application.db.session import DatabaseSession with DatabaseSession(logger) as session: + print("Get user") try: - return session.query(User).filter(User.login_name == username).one() + user = session.query(User).filter(User.login_name == username).one() + print(user.__dict__) + return user except NoResultFound: return None @@ -262,6 +271,7 @@ def create_user(username: str, password: str, email: str, first_name:str, last_n if username is None or password is None or email is None or first_name is None or last_name is None: return {"status": "Missing field"} + with DatabaseSession(logger) as session: user = session.query(User).filter(User.login_name == username).first() From 607d568cff0f190726f404de049d764539c8ff9b Mon Sep 17 00:00:00 2001 From: Andrew Brain Date: Fri, 6 Jan 2023 12:11:49 -0600 Subject: [PATCH 048/150] Add function to paginate all repos, user repos and group repos Signed-off-by: Andrew Brain --- augur/util/repo_load_controller.py | 83 ++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/augur/util/repo_load_controller.py b/augur/util/repo_load_controller.py index e96ec0676e..c399ced86e 100644 --- a/augur/util/repo_load_controller.py +++ b/augur/util/repo_load_controller.py @@ -511,3 +511,86 @@ def parse_org_url(self, url): # if the result is not None then the groups should be valid so we don't worry about index errors here return result.groups()[0] + + def paginate_repos(self, source, page=0, page_size=25, sort="repo_id", direction="ASC", **kwargs): + + + if not source: + return {"status": "Missing argument"} + + if source not in ["all", "user", "group"]: + return {"status": "Invalid source"} + + if direction and direction != "ASC" and direction != "DESC": + return {"status": "Invalid direction"} + + try: + page = int(page) + page_size = int(page_size) + except ValueError: + return {"status": "Page size and page should be integers"} + + if page < 0 or page_size < 0: + return {"status": "Page size and page should be postive"} + + order_by = sort if sort else "repo_id" + order_direction = direction if direction else "ASC" + + query = """ + SELECT + augur_data.repo.repo_id, + augur_data.repo.repo_name, + augur_data.repo.description, + augur_data.repo.repo_git AS url, + augur_data.repo.repo_status, + a.commits_all_time, + b.issues_all_time, + rg_name, + augur_data.repo.repo_group_id + FROM + augur_data.repo + LEFT OUTER JOIN augur_data.api_get_all_repos_commits a ON augur_data.repo.repo_id = a.repo_id + LEFT OUTER JOIN augur_data.api_get_all_repos_issues b ON augur_data.repo.repo_id = b.repo_id + JOIN augur_data.repo_groups ON augur_data.repo.repo_group_id = augur_data.repo_groups.repo_group_id""" + + if source == "user": + + user = kwargs["user"] + group_ids = [group.group_id for group in user.groups] + + query.append("JOIN augur_operations.user_repos ON augur_data.repo.repo_id = augur_operations.user_repos.repo_id") + query.append(f"WHERE augur_operations.user_repos.group_id in {str(group_ids)}") + + elif source == "group": + + with GithubTaskSession(logger) as session: + + controller = RepoLoadController(session) + user = kwargs["user"] + group_name = kwargs["group_name"] + + group_id = controller.convert_group_name_to_id(user, group_name) + if group_id is None: + return {"status": "Group does not exist"} + + query.append("JOIN augur_operations.user_repos ON augur_data.repo.repo_id = augur_operations.user_repos.repo_id") + query.append(f"WHERE augur_operations.user_repos.group_id = {group_id}") + + query.append(f"ORDER BY {order_by} {order_direction}") + query.append(f"LIMIT {page_size}") + query.append(f"OFFSET {page*page_size};") + + get_page_of_repos_sql = text(base_query) + + results = pd.read_sql(get_page_of_repos_sql, create_database_engine()) + results['url'] = results['url'].apply(lambda datum: datum.split('//')[1]) + + b64_urls = [] + for i in results.index: + b64_urls.append(base64.b64encode((results.at[i, 'url']).encode())) + results['base64_url'] = b64_urls + + data = results.to_json(orient="records", date_format='iso', date_unit='ms') + + return {"status": "success", "data": data} + From 228bb7ac6a675455b408186ba5213a28c7917935 Mon Sep 17 00:00:00 2001 From: Andrew Brain Date: Fri, 6 Jan 2023 15:20:11 -0600 Subject: [PATCH 049/150] A functions to paginate user, group and all repos Signed-off-by: Andrew Brain --- augur/api/routes/util.py | 29 ++++ .../application/db/models/augur_operations.py | 124 ++++++-------- augur/util/repo_load_controller.py | 156 +++++++++++++----- 3 files changed, 194 insertions(+), 115 deletions(-) diff --git a/augur/api/routes/util.py b/augur/api/routes/util.py index 8f9086b5ec..2c653d9594 100644 --- a/augur/api/routes/util.py +++ b/augur/api/routes/util.py @@ -13,6 +13,35 @@ AUGUR_API_VERSION = 'api/unstable' +def get_all_repos(self, page=0, page_size=25, sort="repo_id", direction="ASC"): + + from augur.tasks.github.util.github_task_session import GithubTaskSession + from augur.util.repo_load_controller import RepoLoadController + + with GithubTaskSession(logger) as session: + + with RepoLoadController(session) as controller: + + result = controller.paginate_repos("all", page, page_size, sort, direction) + + return result + +def get_all_repos_count(self): + + from augur.tasks.github.util.github_task_session import GithubTaskSession + from augur.util.repo_load_controller import RepoLoadController + + with GithubTaskSession(logger) as session: + + controller = RepoLoadController(session) + + result = controller.get_repo_count(source="all") + if result["status"] == "success": + return result["repos"] + + return result["status"] + + def create_routes(server): @server.app.route('/{}/repo-groups'.format(AUGUR_API_VERSION)) diff --git a/augur/application/db/models/augur_operations.py b/augur/application/db/models/augur_operations.py index d9c7fcb15e..fd7e0f7079 100644 --- a/augur/application/db/models/augur_operations.py +++ b/augur/application/db/models/augur_operations.py @@ -389,75 +389,58 @@ def get_group_names(self): return {"group_names": group_names} + def query_repos(self): + from augur.application.db.session import DatabaseSession + from augur.application.db.models.augur_data import Repo + + with DatabaseSession(logger) as session: + + return [repo.repo_id for repo in session.query(Repo).all()] + + + def get_repos(self, group_name, page=0, page_size=25, sort="repo_id", direction="ASC"): - def get_group_repos(self, group_name, page=0, page_size=25, sort="repo_id", direction="ASC"): - from augur.tasks.github.util.github_task_session import GithubTaskSession from augur.util.repo_load_controller import RepoLoadController - - if not group_name: - return {"status": "Missing argument"} - if direction and direction != "ASC" and direction != "DESC": - return {"status": "Invalid direction"} + with GithubTaskSession(logger) as session: - try: - page = int(page) - page_size = int(page_size) - except ValueError: - return {"status": "Page size and page should be integers"} + with RepoLoadController(session) as controller: - if page < 0 or page_size < 0: - return {"status": "Page size and page should be postive"} + result = controller.paginate_repos("user", page, page_size, sort, direction, user=self, group_name=group_name) + return result + + def get_repo_count(self): + + from augur.tasks.github.util.github_task_session import GithubTaskSession + from augur.util.repo_load_controller import RepoLoadController with GithubTaskSession(logger) as session: controller = RepoLoadController(session) - - group_id = controller.convert_group_name_to_id(user.user_id, group_name) - if group_id is None: - return {"status": "Group does not exist"} - - - order_by = sort if sort else "repo_id" - order_direction = direction if direction else "ASC" - - get_page_of_repos_sql = text(f""" - SELECT - augur_data.repo.repo_id, - augur_data.repo.repo_name, - augur_data.repo.description, - augur_data.repo.repo_git AS url, - augur_data.repo.repo_status, - a.commits_all_time, - b.issues_all_time, - rg_name, - augur_data.repo.repo_group_id - FROM - augur_data.repo - LEFT OUTER JOIN augur_data.api_get_all_repos_commits a ON augur_data.repo.repo_id = a.repo_id - LEFT OUTER JOIN augur_data.api_get_all_repos_issues b ON augur_data.repo.repo_id = b.repo_id - JOIN augur_operations.user_repos ON augur_data.repo.repo_id = augur_operations.user_repos.repo_id - JOIN augur_data.repo_groups ON augur_data.repo.repo_group_id = augur_data.repo_groups.repo_group_id - WHERE augur_operations.user_repos.group_id = {group_id} - ORDER BY {order_by} {order_direction} - LIMIT {page_size} - OFFSET {page*page_size}; - """) - - results = pd.read_sql(get_page_of_repos_sql, create_database_engine()) - results['url'] = results['url'].apply(lambda datum: datum.split('//')[1]) - - b64_urls = [] - for i in results.index: - b64_urls.append(base64.b64encode((results.at[i, 'url']).encode())) - results['base64_url'] = b64_urls - - data = results.to_json(orient="records", date_format='iso', date_unit='ms') - - return {"status": "success", "data": data} + + result = controller.get_repo_count(source="user", user=self) + if result["status"] == "success": + return result["repos"] + + return result["status"] + + + def get_group_repos(self, group_name, page=0, page_size=25, sort="repo_id", direction="ASC"): + + from augur.tasks.github.util.github_task_session import GithubTaskSession + from augur.util.repo_load_controller import RepoLoadController + + with GithubTaskSession(logger) as session: + + with RepoLoadController(session) as controller: + + result = controller.paginate_repos("group", page, page_size, sort, direction, user=self, group_name=group_name) + + return result + def get_group_repo_count(self, group_name): @@ -468,25 +451,14 @@ def get_group_repo_count(self, group_name): controller = RepoLoadController(session) - group_id = controller.convert_group_name_to_id(user.user_id, group_name) - if group_id is None: - return {"status": "Group does not exist"} - - get_page_of_repos_sql = text(f""" - SELECT - count(*) - FROM - augur_data.repo - LEFT OUTER JOIN augur_data.api_get_all_repos_commits a ON augur_data.repo.repo_id = a.repo_id - LEFT OUTER JOIN augur_data.api_get_all_repos_issues b ON augur_data.repo.repo_id = b.repo_id - JOIN augur_operations.user_repos ON augur_data.repo.repo_id = augur_operations.user_repos.repo_id - JOIN augur_data.repo_groups ON augur_data.repo.repo_group_id = augur_data.repo_groups.repo_group_id - WHERE augur_operations.user_repos.group_id = {group_id} - """) - - result = session.fetchall_data_from_sql_text(get_page_of_repos_sql) - - return {"repos": result[0]["count"]} + result = controller.get_repo_count(source="group", group_name=group_name, user=self) + if result["status"] == "success": + return result["repos"] + + return result["status"] + + + class UserGroup(Base): diff --git a/augur/util/repo_load_controller.py b/augur/util/repo_load_controller.py index c399ced86e..f916d046e7 100644 --- a/augur/util/repo_load_controller.py +++ b/augur/util/repo_load_controller.py @@ -512,9 +512,9 @@ def parse_org_url(self, url): # if the result is not None then the groups should be valid so we don't worry about index errors here return result.groups()[0] + def paginate_repos(self, source, page=0, page_size=25, sort="repo_id", direction="ASC", **kwargs): - if not source: return {"status": "Missing argument"} @@ -536,61 +536,139 @@ def paginate_repos(self, source, page=0, page_size=25, sort="repo_id", direction order_by = sort if sort else "repo_id" order_direction = direction if direction else "ASC" - query = """ - SELECT - augur_data.repo.repo_id, - augur_data.repo.repo_name, - augur_data.repo.description, - augur_data.repo.repo_git AS url, - augur_data.repo.repo_status, - a.commits_all_time, - b.issues_all_time, - rg_name, - augur_data.repo.repo_group_id - FROM - augur_data.repo - LEFT OUTER JOIN augur_data.api_get_all_repos_commits a ON augur_data.repo.repo_id = a.repo_id - LEFT OUTER JOIN augur_data.api_get_all_repos_issues b ON augur_data.repo.repo_id = b.repo_id - JOIN augur_data.repo_groups ON augur_data.repo.repo_group_id = augur_data.repo_groups.repo_group_id""" + query = self.generate_repo_query(source, count=False, order_by=order_by, direction=order_direction, + page=page, page_size=page_size) + if isinstance(query, dict): + return query - if source == "user": + get_page_of_repos_sql = text(query) + results = pd.read_sql(get_page_of_repos_sql, create_database_engine()) + results['url'] = results['url'].apply(lambda datum: datum.split('//')[1]) + + b64_urls = [] + for i in results.index: + b64_urls.append(base64.b64encode((results.at[i, 'url']).encode())) + results['base64_url'] = b64_urls + + data = results.to_json(orient="records", date_format='iso', date_unit='ms') + + return {"status": "success", "data": data} + + def get_repo_count(self, source, **kwargs) + + if not source: + return {"status": "Missing argument"} + + if source not in ["all", "user", "group"]: + return {"status": "Invalid source"} + + try: user = kwargs["user"] - group_ids = [group.group_id for group in user.groups] + except ValueError: + user = None + + try: + group_name = kwargs["group_name"] + except ValueError: + group_name = None - query.append("JOIN augur_operations.user_repos ON augur_data.repo.repo_id = augur_operations.user_repos.repo_id") - query.append(f"WHERE augur_operations.user_repos.group_id in {str(group_ids)}") + query = self.generate_repo_query(source, count=True, user=user, group_name=group_name) + if isinstance(query, dict): + return query + + get_page_of_repos_sql = text(query) + + result = self.session.fetchall_data_from_sql_text(get_page_of_repos_sql) + + return {"status": "success", "repos": result[0]["count"]} + + + def generate_repo_query(self, source, count, **kwargs): + + if count: + select = "count(*)" + else: + select = """ augur_data.repo.repo_id, + augur_data.repo.repo_name, + augur_data.repo.description, + augur_data.repo.repo_git AS url, + augur_data.repo.repo_status, + a.commits_all_time, + b.issues_all_time, + rg_name, + augur_data.repo.repo_group_id""" + + query = f""" + SELECT + {select} + FROM + augur_data.repo + LEFT OUTER JOIN augur_data.api_get_all_repos_commits a ON augur_data.repo.repo_id = a.repo_id + LEFT OUTER JOIN augur_data.api_get_all_repos_issues b ON augur_data.repo.repo_id = b.repo_id + JOIN augur_data.repo_groups ON augur_data.repo.repo_group_id = augur_data.repo_groups.repo_group_id\n""" + + if source == "user": + + try: + user = kwargs["user"] + except ValueError: + return {"status": "Missing argument"} + + group_ids = tuple([group.group_id for group in user.groups]) + + if len(group_ids) == 1: + group_ids_str = str(group_ids)[:-2] + ")" + else: + group_ids_str = str(group_ids) + + query += "\t\t JOIN augur_operations.user_repos ON augur_data.repo.repo_id = augur_operations.user_repos.repo_id\n" + query += f"\t\t WHERE augur_operations.user_repos.group_id in {group_ids_str}\n" elif source == "group": with GithubTaskSession(logger) as session: controller = RepoLoadController(session) - user = kwargs["user"] - group_name = kwargs["group_name"] - - group_id = controller.convert_group_name_to_id(user, group_name) + + try: + user = kwargs["user"] + group_name = kwargs["group_name"] + except KeyError: + return {"status": "Missing argument"} + + group_id = controller.convert_group_name_to_id(user.user_id, group_name) if group_id is None: return {"status": "Group does not exist"} - query.append("JOIN augur_operations.user_repos ON augur_data.repo.repo_id = augur_operations.user_repos.repo_id") - query.append(f"WHERE augur_operations.user_repos.group_id = {group_id}") + query += "\t\t JOIN augur_operations.user_repos ON augur_data.repo.repo_id = augur_operations.user_repos.repo_id\n" + query += f"\t\t WHERE augur_operations.user_repos.group_id = {group_id}\n" - query.append(f"ORDER BY {order_by} {order_direction}") - query.append(f"LIMIT {page_size}") - query.append(f"OFFSET {page*page_size};") + if not count: - get_page_of_repos_sql = text(base_query) + try: + order_by = kwargs["order_by"] + except KeyError: + order_by = "repo_id" - results = pd.read_sql(get_page_of_repos_sql, create_database_engine()) - results['url'] = results['url'].apply(lambda datum: datum.split('//')[1]) + try: + direction = kwargs["direction"] + except KeyError: + direction = "ASC" - b64_urls = [] - for i in results.index: - b64_urls.append(base64.b64encode((results.at[i, 'url']).encode())) - results['base64_url'] = b64_urls + try: + page = kwargs["page"] + except KeyError: + page = 0 - data = results.to_json(orient="records", date_format='iso', date_unit='ms') + try: + page_size = kwargs["page_size"] + except KeyError: + page_size = 25 - return {"status": "success", "data": data} + query += f"\t ORDER BY {order_by} {direction}\n" + query += f"\t LIMIT {page_size}\n" + query += f"\t OFFSET {page*page_size};\n" + + return query From 653b40853d289bf6b1c45602269fee60736309cb Mon Sep 17 00:00:00 2001 From: Andrew Brain Date: Fri, 6 Jan 2023 15:22:08 -0600 Subject: [PATCH 050/150] Fix syntax error Signed-off-by: Andrew Brain --- augur/util/repo_load_controller.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/augur/util/repo_load_controller.py b/augur/util/repo_load_controller.py index f916d046e7..af4746065d 100644 --- a/augur/util/repo_load_controller.py +++ b/augur/util/repo_load_controller.py @@ -555,7 +555,7 @@ def paginate_repos(self, source, page=0, page_size=25, sort="repo_id", direction return {"status": "success", "data": data} - def get_repo_count(self, source, **kwargs) + def get_repo_count(self, source, **kwargs): if not source: return {"status": "Missing argument"} From 901452980558bb7dbbc13b01c9511b4302cb9b8c Mon Sep 17 00:00:00 2001 From: Andrew Brain Date: Fri, 6 Jan 2023 15:40:13 -0600 Subject: [PATCH 051/150] Fixes Signed-off-by: Andrew Brain --- augur/api/routes/util.py | 10 +++++----- augur/util/repo_load_controller.py | 8 +++++--- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/augur/api/routes/util.py b/augur/api/routes/util.py index 2c653d9594..519ad9980d 100644 --- a/augur/api/routes/util.py +++ b/augur/api/routes/util.py @@ -13,20 +13,20 @@ AUGUR_API_VERSION = 'api/unstable' -def get_all_repos(self, page=0, page_size=25, sort="repo_id", direction="ASC"): +def get_all_repos(page=0, page_size=25, sort="repo_id", direction="ASC"): from augur.tasks.github.util.github_task_session import GithubTaskSession from augur.util.repo_load_controller import RepoLoadController with GithubTaskSession(logger) as session: - with RepoLoadController(session) as controller: + controller = RepoLoadController(session) - result = controller.paginate_repos("all", page, page_size, sort, direction) + result = controller.paginate_repos("all", page, page_size, sort, direction) - return result + return result -def get_all_repos_count(self): +def get_all_repos_count(): from augur.tasks.github.util.github_task_session import GithubTaskSession from augur.util.repo_load_controller import RepoLoadController diff --git a/augur/util/repo_load_controller.py b/augur/util/repo_load_controller.py index af4746065d..6f4ff75f98 100644 --- a/augur/util/repo_load_controller.py +++ b/augur/util/repo_load_controller.py @@ -2,10 +2,12 @@ import logging import sqlalchemy as s +import pandas as pd +import base64 from typing import List, Any, Dict - +from augur.application.db.engine import create_database_engine from augur.tasks.github.util.github_paginator import hit_api from augur.tasks.github.util.github_paginator import GithubPaginator from augur.tasks.github.util.github_task_session import GithubTaskSession @@ -541,7 +543,7 @@ def paginate_repos(self, source, page=0, page_size=25, sort="repo_id", direction if isinstance(query, dict): return query - get_page_of_repos_sql = text(query) + get_page_of_repos_sql = s.sql.text(query) results = pd.read_sql(get_page_of_repos_sql, create_database_engine()) results['url'] = results['url'].apply(lambda datum: datum.split('//')[1]) @@ -577,7 +579,7 @@ def get_repo_count(self, source, **kwargs): if isinstance(query, dict): return query - get_page_of_repos_sql = text(query) + get_page_of_repos_sql = s.sql.text(query) result = self.session.fetchall_data_from_sql_text(get_page_of_repos_sql) From bbe459364fa055c7e4df5d8f36246b01946d74b9 Mon Sep 17 00:00:00 2001 From: Andrew Brain Date: Fri, 6 Jan 2023 16:50:56 -0600 Subject: [PATCH 052/150] Fix various bugs Signed-off-by: Andrew Brain --- augur/api/view/augur_view.py | 3 --- augur/api/view/routes.py | 15 +++++---------- 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/augur/api/view/augur_view.py b/augur/api/view/augur_view.py index 2347791d68..2da7f94f1d 100644 --- a/augur/api/view/augur_view.py +++ b/augur/api/view/augur_view.py @@ -35,12 +35,9 @@ def unauthorized(): @login_manager.user_loader def load_user(user_id): - print("Loading user") - user = User.get_user(user_id) if not user: - print("User not found") return None # The flask_login library sets a unique session["_id"] diff --git a/augur/api/view/routes.py b/augur/api/view/routes.py index ef2e635ede..48ad649d62 100644 --- a/augur/api/view/routes.py +++ b/augur/api/view/routes.py @@ -133,20 +133,18 @@ def user_login(): username = request.form.get('username') remember = request.form.get('remember') is not None password = request.form.get('password') + register = request.form.get('register') if username is None: raise LoginException("A login issue occurred") - # test if the user does not exist then the login is invalid user = User.get_user(username) - if not user and request.form.get('register'): + if not user and register is None: raise LoginException("Invalid login credentials") - + # register a user - if request.form.get('register') is not None: - print("Register user") + if register is not None: if user: - print(f"User already exists: {user.__dict__}") raise LoginException("User already exists") email = request.form.get('email') @@ -162,10 +160,7 @@ def user_login(): flash(result["status"]) # Log the user in if the password is valid - if user.validate(password): - - result = login_user(user, remember = remember) - print(result) + if user.validate(password) and login_user(user, remember = remember): flash(f"Welcome, {username}!") if "login_next" in session: return redirect(session.pop("login_next")) From 5c4ceb97ebf0a8877643e95adf15e31e5b9e14a0 Mon Sep 17 00:00:00 2001 From: Andrew Brain Date: Sat, 7 Jan 2023 10:30:11 -0600 Subject: [PATCH 053/150] Remove prints Signed-off-by: Andrew Brain --- augur/application/db/models/augur_operations.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/augur/application/db/models/augur_operations.py b/augur/application/db/models/augur_operations.py index fd7e0f7079..c6bcd1d2f2 100644 --- a/augur/application/db/models/augur_operations.py +++ b/augur/application/db/models/augur_operations.py @@ -245,8 +245,6 @@ def validate(self, password) -> bool: return False result = check_password_hash(self.login_hashword, password) - print(f"Validating: {result}") - return result @staticmethod @@ -255,10 +253,8 @@ def get_user(username: str): from augur.application.db.session import DatabaseSession with DatabaseSession(logger) as session: - print("Get user") try: user = session.query(User).filter(User.login_name == username).one() - print(user.__dict__) return user except NoResultFound: return None From e4b2a04a2b13e283de9139c135ff61e522beaa6f Mon Sep 17 00:00:00 2001 From: Andrew Brain Date: Sat, 7 Jan 2023 11:02:48 -0600 Subject: [PATCH 054/150] Make json endpoints only work when logged in Signed-off-by: Andrew Brain --- augur/api/routes/user.py | 53 +++++++++++++++++++++++++--------------- 1 file changed, 33 insertions(+), 20 deletions(-) diff --git a/augur/api/routes/user.py b/augur/api/routes/user.py index 449f98b4ca..5677b0e844 100644 --- a/augur/api/routes/user.py +++ b/augur/api/routes/user.py @@ -132,15 +132,28 @@ def validate_user(): return jsonify({"status": "Missing argument"}), 400 user = session.query(User).filter(User.login_name == username).first() - checkPassword = check_password_hash(user.login_hashword, password) if user is None: return jsonify({"status": "Invalid username"}) + + checkPassword = check_password_hash(user.login_hashword, password) if checkPassword == False: return jsonify({"status": "Invalid password"}) login_user(user) return jsonify({"status": "Validated"}) + + @server.app.route(f"/{AUGUR_API_VERSION}/user/logout", methods=['POST']) + @login_required + def logout_user_func(): + if not development and not request.is_secure: + return generate_upgrade_request() + + if logout_user(): + return jsonify({"status": "Logged out"}) + + return jsonify({"status": "Error when logging out"}) + @server.app.route(f"/{AUGUR_API_VERSION}/user/oauth", methods=['POST']) def oauth_validate(): @@ -180,7 +193,7 @@ def generate_session(): return jsonify({"status": "Validated", "username": user, "session": token}) @server.app.route(f"/{AUGUR_API_VERSION}/user/query", methods=['POST']) - def query_user(user): + def query_user(): if not development and not request.is_secure: return generate_upgrade_request() @@ -215,7 +228,7 @@ def create_user(): @server.app.route(f"/{AUGUR_API_VERSION}/user/remove", methods=['POST', 'DELETE']) @login_required - def delete_user(user): + def delete_user(): if not development and not request.is_secure: return generate_upgrade_request() @@ -235,7 +248,7 @@ def delete_user(user): @server.app.route(f"/{AUGUR_API_VERSION}/user/update", methods=['POST']) @login_required - def update_user(user): + def update_user(): if not development and not request.is_secure: return generate_upgrade_request() @@ -272,60 +285,60 @@ def update_user(user): @server.app.route(f"/{AUGUR_API_VERSION}/user/add_repo", methods=['GET', 'POST']) @login_required - def add_user_repo(user): + def add_user_repo(): if not development and not request.is_secure: return generate_upgrade_request() repo = request.args.get("repo_url") group_name = request.args.get("group_name") - result = user.add_repo(group_name, repo) + result = current_user.add_repo(group_name, repo) return jsonify(result) @server.app.route(f"/{AUGUR_API_VERSION}/user/add_group", methods=['GET', 'POST']) @login_required - def add_user_group(user): + def add_user_group(): if not development and not request.is_secure: return generate_upgrade_request() group_name = request.args.get("group_name") - result = user.add_group(group_name) + result = current_user.add_group(group_name) return jsonify(result) @server.app.route(f"/{AUGUR_API_VERSION}/user/remove_group", methods=['GET', 'POST']) @login_required - def remove_user_group(user): + def remove_user_group(): if not development and not request.is_secure: return generate_upgrade_request() group_name = request.args.get("group_name") - result = user.remove_group(group_name) + result = current_user.remove_group(group_name) return jsonify(result) @server.app.route(f"/{AUGUR_API_VERSION}/user/add_org", methods=['GET', 'POST']) @login_required - def add_user_org(user): + def add_user_org(): if not development and not request.is_secure: return generate_upgrade_request() org = request.args.get("org_url") group_name = request.args.get("group_name") - result = user.add_org(group_name, org_url) + result = current_user.add_org(group_name, org_url) return jsonify(result) @server.app.route(f"/{AUGUR_API_VERSION}/user/remove_repo", methods=['GET', 'POST']) @login_required - def remove_user_repo(user): + def remove_user_repo(): if not development and not request.is_secure: return generate_upgrade_request() @@ -333,14 +346,14 @@ def remove_user_repo(user): group_name = request.args.get("group_name") repo_id = request.args.get("repo_id") - result = user.remove_repo(group_name, repo_id) + result = current_user.remove_repo(group_name, repo_id) return jsonify(result) @server.app.route(f"/{AUGUR_API_VERSION}/user/group_repos", methods=['GET', 'POST']) @login_required - def group_repos(user): + def group_repos(): """Select repos from a user group by name Arguments @@ -373,7 +386,7 @@ def group_repos(user): sort = request.args.get("sort") or "repo_id" direction = request.args.get("direction") or "ASC" - result = user.get_group_repos(group_name, page, page_size, sort, direction) + result = current_user.get_group_repos(group_name, page, page_size, sort, direction) return jsonify(result) @@ -381,7 +394,7 @@ def group_repos(user): @server.app.route(f"/{AUGUR_API_VERSION}/user/group_repo_count", methods=['GET', 'POST']) @login_required - def group_repo_count(user): + def group_repo_count(): """Count repos from a user group by name Arguments @@ -402,14 +415,14 @@ def group_repo_count(user): group_name = request.args.get("group_name") - result = user.group_repo_count(group_name) + result = current_user.group_repo_count(group_name) return jsonify(result) @server.app.route(f"/{AUGUR_API_VERSION}/user/groups", methods=['GET', 'POST']) @login_required - def get_user_groups(user): + def get_user_groups(): """Get a list of user groups by username Arguments @@ -426,7 +439,7 @@ def get_user_groups(user): if not development and not request.is_secure: return generate_upgrade_request() - result = user.get_groups() + result = current_user.get_groups() return jsonify(result) From 23928c15238323ceafcdeb068ac3ee908b37bfef Mon Sep 17 00:00:00 2001 From: Andrew Brain Date: Sat, 7 Jan 2023 11:18:59 -0600 Subject: [PATCH 055/150] Return error if user is not logged in when using the api Signed-off-by: Andrew Brain --- augur/api/view/augur_view.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/augur/api/view/augur_view.py b/augur/api/view/augur_view.py index 2da7f94f1d..a04b3a78f1 100644 --- a/augur/api/view/augur_view.py +++ b/augur/api/view/augur_view.py @@ -1,7 +1,8 @@ -from flask import Flask, render_template, redirect, url_for, session, request +from flask import Flask, render_template, redirect, url_for, session, request, jsonify from flask_login import LoginManager from .utils import * from .url_converters import * + # from .server import User from augur.application.db.models import User from augur.application.db.session import DatabaseSession @@ -29,6 +30,9 @@ def unsupported_method(error): @login_manager.unauthorized_handler def unauthorized(): + if "/api/unstable/" in str(request.url_rule): + return jsonify({"status": "Login required"}) + session["login_next"] = url_for(request.endpoint, **request.args) return redirect(url_for('user_login')) @@ -44,7 +48,6 @@ def load_user(user_id): # when login_user() is called successfully if session.get("_id") is not None: - # TODO: Add these as properties user._is_authenticated = True user._is_active = True From be82983f486a946eaea10011478fd4b3b8fd39a5 Mon Sep 17 00:00:00 2001 From: Andrew Brain Date: Sat, 7 Jan 2023 12:48:46 -0600 Subject: [PATCH 056/150] Add more function to orm Signed-off-by: Andrew Brain --- augur/api/routes/user.py | 22 ++----- .../application/db/models/augur_operations.py | 63 +++++++++++++++++-- 2 files changed, 63 insertions(+), 22 deletions(-) diff --git a/augur/api/routes/user.py b/augur/api/routes/user.py index 5677b0e844..eeb2669bd9 100644 --- a/augur/api/routes/user.py +++ b/augur/api/routes/user.py @@ -197,14 +197,11 @@ def query_user(): if not development and not request.is_secure: return generate_upgrade_request() - session = Session() username = request.args.get("username") if username is None: - # https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/400 return jsonify({"status": "Missing argument"}), 400 - user = session.query(User).filter(User.login_name == username).first() - - if user is None: + + if not User.exists(username): return jsonify({"status": "Invalid username"}) return jsonify({"status": True}) @@ -232,19 +229,9 @@ def delete_user(): if not development and not request.is_secure: return generate_upgrade_request() - session = Session() - - for group in user.groups: - user_repos_list = group.repos - - for user_repo_entry in user_repos_list: - session.delete(user_repo_entry) + status = current_user.delete() + return jsonify(status) - session.delete(group) - - session.delete(user) - session.commit() - return jsonify({"status": "User deleted"}), 200 @server.app.route(f"/{AUGUR_API_VERSION}/user/update", methods=['POST']) @login_required @@ -252,7 +239,6 @@ def update_user(): if not development and not request.is_secure: return generate_upgrade_request() - session = Session() email = request.args.get("email") new_login_name = request.args.get("new_username") new_password = request.args.get("new_password") diff --git a/augur/application/db/models/augur_operations.py b/augur/application/db/models/augur_operations.py index c6bcd1d2f2..ee8d67812a 100644 --- a/augur/application/db/models/augur_operations.py +++ b/augur/application/db/models/augur_operations.py @@ -250,6 +250,9 @@ def validate(self, password) -> bool: @staticmethod def get_user(username: str): + if not username: + return None + from augur.application.db.session import DatabaseSession with DatabaseSession(logger) as session: @@ -286,15 +289,67 @@ def create_user(username: str, password: str, email: str, first_name:str, last_n except AssertionError as exception_message: return {"Error": f"{exception_message}."} - @staticmethod - def delete_user(): + def delete_user(self): from augur.application.db.session import DatabaseSession - def update_user(self): + with DatabaseSession(logger) as session: + + for group in self.groups: + user_repos_list = group.repos + + for user_repo_entry in user_repos_list: + session.delete(user_repo_entry) + + session.delete(group) + + session.delete(self) + session.commit() + + return {"status": "User deleted"}) + + def update_user(self, **kwargs): from augur.application.db.session import DatabaseSession - pass + + with DatabaseSession(session) as session: + + valid_kwargs = ["email", "password", "username"] + + kwarg_present = False + for value in valid_kwargs: + + if value in kwargs: + if kwarg_present: + return {"status": "Please pass an email, password, or username, not multiple"} + + kwarg_present = True + + if "email" in kwargs: + + existing_user = session.query(User).filter(User.email == kwargs["email"]).one() + if existing_user is not None: + return jsonify({"status": "Already an account with this email"}) + + user.email = kwargs["email"] + session.commit() + return jsonify({"status": "Email Updated"}) + + if "password" in kwargs: + user.login_hashword = generate_password_hash(kwargs["password"]) + session.commit() + return jsonify({"status": "Password Updated"}) + + if "username" in kwargs: + existing_user = session.query(User).filter(User.login_name == kwargs["username"]).one() + if existing_user is not None: + return jsonify({"status": "Username already taken"}) + + user.login_name = kwargs["username"] + session.commit() + return jsonify({"status": "Username Updated"}) + + return jsonify({"status": "Missing argument"}), 400 def add_group(self, group_name): From a8541a980e81f75b163994d7d4058cb00ab617b0 Mon Sep 17 00:00:00 2001 From: Ulincsys <28362836a@gmail.com> Date: Sat, 7 Jan 2023 20:10:44 -0600 Subject: [PATCH 057/150] Integration work --- augur/api/routes/__init__.py | 1 + augur/api/routes/batch.py | 2 +- augur/api/routes/collection_status.py | 2 +- augur/api/routes/config.py | 2 +- augur/api/routes/contributor_reports.py | 2 +- augur/api/routes/manager.py | 2 +- augur/api/routes/metadata.py | 2 +- augur/api/routes/nonstandard_metrics.py | 2 +- augur/api/routes/pull_request_reports.py | 2 +- augur/api/routes/user.py | 110 ++++++------------ augur/api/routes/util.py | 2 +- augur/api/server.py | 2 +- augur/api/util.py | 32 ++++- augur/api/view/api.py | 23 ---- augur/api/view/augur_view.py | 18 ++- augur/api/view/init.py | 1 - augur/api/view/routes.py | 24 +++- .../application/db/models/augur_operations.py | 6 +- augur/templates/authorization.html | 41 +++++++ augur/templates/login.html | 6 +- 20 files changed, 164 insertions(+), 118 deletions(-) create mode 100644 augur/templates/authorization.html diff --git a/augur/api/routes/__init__.py b/augur/api/routes/__init__.py index e69de29bb2..f4cc69cb4e 100644 --- a/augur/api/routes/__init__.py +++ b/augur/api/routes/__init__.py @@ -0,0 +1 @@ +AUGUR_API_VERSION = 'api/unstable' diff --git a/augur/api/routes/batch.py b/augur/api/routes/batch.py index a967fb4f64..bb08bbc5a1 100644 --- a/augur/api/routes/batch.py +++ b/augur/api/routes/batch.py @@ -12,7 +12,7 @@ from augur.api.util import metric_metadata import json -AUGUR_API_VERSION = 'api/unstable' +from augur.api.routes import AUGUR_API_VERSION logger = logging.getLogger(__name__) diff --git a/augur/api/routes/collection_status.py b/augur/api/routes/collection_status.py index fb8ea0f318..49c62e2d76 100644 --- a/augur/api/routes/collection_status.py +++ b/augur/api/routes/collection_status.py @@ -4,7 +4,7 @@ import json from flask import Response -AUGUR_API_VERSION = 'api/unstable' +from augur.api.routes import AUGUR_API_VERSION def create_routes(server): diff --git a/augur/api/routes/config.py b/augur/api/routes/config.py index 7a3d7f7014..3dad0105d1 100644 --- a/augur/api/routes/config.py +++ b/augur/api/routes/config.py @@ -17,7 +17,7 @@ logger = logging.getLogger(__name__) development = get_development_flag() -AUGUR_API_VERSION = 'api/unstable' +from augur.api.routes import AUGUR_API_VERSION def generate_upgrade_request(): # https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/426 diff --git a/augur/api/routes/contributor_reports.py b/augur/api/routes/contributor_reports.py index 7425695825..0d599e6acf 100644 --- a/augur/api/routes/contributor_reports.py +++ b/augur/api/routes/contributor_reports.py @@ -18,7 +18,7 @@ from bokeh.layouts import gridplot from bokeh.transform import cumsum -AUGUR_API_VERSION = 'api/unstable' +from augur.api.routes import AUGUR_API_VERSION warnings.filterwarnings('ignore') diff --git a/augur/api/routes/manager.py b/augur/api/routes/manager.py index 7624322a02..fcb5524663 100755 --- a/augur/api/routes/manager.py +++ b/augur/api/routes/manager.py @@ -17,7 +17,7 @@ import os import traceback -AUGUR_API_VERSION = 'api/unstable' +from augur.api.routes import AUGUR_API_VERSION logger = logging.getLogger(__name__) diff --git a/augur/api/routes/metadata.py b/augur/api/routes/metadata.py index 7a0f1ff20a..8d4cad3c5a 100644 --- a/augur/api/routes/metadata.py +++ b/augur/api/routes/metadata.py @@ -12,7 +12,7 @@ import os import requests -AUGUR_API_VERSION = 'api/unstable' +from augur.api.routes import AUGUR_API_VERSION def create_routes(server): diff --git a/augur/api/routes/nonstandard_metrics.py b/augur/api/routes/nonstandard_metrics.py index d57ce50a10..71ac2ff13a 100644 --- a/augur/api/routes/nonstandard_metrics.py +++ b/augur/api/routes/nonstandard_metrics.py @@ -11,7 +11,7 @@ # from augur.api.server import transform from augur.api.server import server -AUGUR_API_VERSION = 'api/unstable' +from augur.api.routes import AUGUR_API_VERSION def create_routes(server): diff --git a/augur/api/routes/pull_request_reports.py b/augur/api/routes/pull_request_reports.py index 1cfa33c9a2..b130e403a2 100644 --- a/augur/api/routes/pull_request_reports.py +++ b/augur/api/routes/pull_request_reports.py @@ -23,7 +23,7 @@ warnings.filterwarnings('ignore') -AUGUR_API_VERSION = 'api/unstable' +from augur.api.routes import AUGUR_API_VERSION def create_routes(server): def pull_request_data_collection(repo_id, start_date, end_date): diff --git a/augur/api/routes/user.py b/augur/api/routes/user.py index eeb2669bd9..f88b6d47a3 100644 --- a/augur/api/routes/user.py +++ b/augur/api/routes/user.py @@ -1,6 +1,6 @@ #SPDX-License-Identifier: MIT """ -Creates routes for user login functionality +Creates routes for user functionality """ import logging @@ -9,7 +9,7 @@ import os import base64 import pandas as pd -from flask import request, Response, jsonify +from flask import request, Response, jsonify, session from flask_login import login_user, logout_user, current_user, login_required from werkzeug.security import generate_password_hash, check_password_hash from sqlalchemy.sql import text @@ -18,66 +18,25 @@ from augur.application.db.session import DatabaseSession from augur.tasks.github.util.github_task_session import GithubTaskSession from augur.util.repo_load_controller import RepoLoadController - +from augur.api.util import get_bearer_token +from augur.api.util import get_client_token from augur.application.db.models import User, UserRepo, UserGroup, UserSessionToken, ClientToken from augur.application.config import get_development_flag +from augur.tasks.init.redis_connection import redis_connection as redis + logger = logging.getLogger(__name__) development = get_development_flag() from augur.application.db.engine import create_database_engine Session = sessionmaker(bind=create_database_engine()) -AUGUR_API_VERSION = 'api/unstable' - -""" - Extract Bearer token from request header, - using the standard oauth2 format -""" -def get_bearer_token(request): - token = request.headers.get("Authorization") - - if token and " " in token: - token = token.split(" ") - if len(token) == 2: - return token[1] - - for substr in token: - if substr and "Bearer" not in substr: - return substr - - return token - -def user_login_required(fun): - def wrapper(*args, **kwargs): - # TODO check that user session token is valid - - # We still need to decide on the format for this - - user_token = request.args.get("user_token") - print(user_token) - - # If valid: - if user_token: - - session = Session() - try: - user = session.query(UserSessionToken).filter(UserSessionToken.token == user_token).one().user - - return fun(user=user, *args, **kwargs) - except NoResultFound: - print("Not found") - - # else: return error JSON - return {"status": "Invalid user session"} - - wrapper.__name__ = fun.__name__ - return wrapper +from augur.api.routes import AUGUR_API_VERSION def api_key_required(fun): def wrapper(*args, **kwargs): # TODO check that API key is valid - client_token = request.args.get("client_api_key") + client_token = get_client_token() # If valid: if client_token: @@ -155,42 +114,43 @@ def logout_user_func(): return jsonify({"status": "Error when logging out"}) - @server.app.route(f"/{AUGUR_API_VERSION}/user/oauth", methods=['POST']) - def oauth_validate(): - # Check if user has an active session - current_session = request.args.get("session") - - if current_session: - # TODO validate session token - # If invalid, set current_session to None to force validation - pass - - if not current_session: - return jsonify({"status": "Invalid session"}) - - # TODO generate oauth token and store in temporary table - # Ideally should be valid for ~1 minute - # oauth entry: (token: str, generated: date) + @server.app.route(f"/{AUGUR_API_VERSION}/user/authorize", methods=['POST', 'GET']) + @login_required + def user_authorize(): + code = secrets.token_hex() + username = current_user.login_name - token = "TEMPORARY VALUE" + redis.set(token, username, ex=30) - return jsonify({"status": "Validated", "oauth_token": token}) + return jsonify({"status": "Validated", "code": code}) @server.app.route(f"/{AUGUR_API_VERSION}/user/generate_session", methods=['POST']) + @api_key_required def generate_session(): # TODO Validate oauth token - oauth = request.args.get("oauth_token") + code = request.args.get("code") + + username = redis.get(code) + redis.delete(code) + if not username: + return jsonify({"status": "Invalid authorization code"}) - # If invalid, return error JSON: - # return jsonify({"status": "Invalid oauth token"}) + user = User.get_user(username) + if not user: + return jsonify({"status": "Invalid user"}) - # If valid, pop oauth token from temporary table - # Generate user session token to be stored in client browser + user_session_token = secrets.token_hex() + seconds_to_expire = 86400 + expiration = int(time.time()) + seconds_to_expire - token = "USER SESSION TOKEN" - user = "USERNAME" + session = Session() + user_session = UserSessionToken(token=user_session_token, user_id=user.user_id, expiration=expiration) - return jsonify({"status": "Validated", "username": user, "session": token}) + session.add(user_session) + session.commit() + session.close() + + return jsonify({"status": "Validated", "username": username, "access_token": user_session_token, "token_type": "Bearer", "expires": seconds_to_expire}) @server.app.route(f"/{AUGUR_API_VERSION}/user/query", methods=['POST']) def query_user(): diff --git a/augur/api/routes/util.py b/augur/api/routes/util.py index 519ad9980d..57df12c7aa 100644 --- a/augur/api/routes/util.py +++ b/augur/api/routes/util.py @@ -11,7 +11,7 @@ logger = AugurLogger("augur").get_logger() -AUGUR_API_VERSION = 'api/unstable' +from augur.api.routes import AUGUR_API_VERSION def get_all_repos(page=0, page_size=25, sort="repo_id", direction="ASC"): diff --git a/augur/api/server.py b/augur/api/server.py index 0d41dedb4f..edc1c34051 100644 --- a/augur/api/server.py +++ b/augur/api/server.py @@ -26,7 +26,7 @@ from augur.application.logs import AugurLogger from metadata import __version__ as augur_code_version -AUGUR_API_VERSION = 'api/unstable' +from augur.api.routes import AUGUR_API_VERSION diff --git a/augur/api/util.py b/augur/api/util.py index 9beaf9e9cd..5a2eb7a703 100644 --- a/augur/api/util.py +++ b/augur/api/util.py @@ -9,6 +9,8 @@ import sys import beaker +from flask import request + __ROOT = os.path.abspath(os.path.dirname(__file__)) def get_data_path(path): """ @@ -73,4 +75,32 @@ def decorate(function): function.metadata.update(metadata) return function - return decorate \ No newline at end of file + return decorate + +""" + Extract authorization token by type from request header +""" +def get_token(token_type): + auth = request.headers.get("Authorization") + + tokens = auth.split(",") + + tokens = filter(lambda x: f"{token_type} " in x, tokens) + + if len(tokens) != 1: + return None + + return tokens[0].replace(f"{token_type} ", "") + +""" + Extract Bearer token from request header +""" +def get_bearer_token(): + return get_token("Bearer") + +""" + Extract Client token from request header +""" +def get_bearer_token(): + return get_token("Client") + \ No newline at end of file diff --git a/augur/api/view/api.py b/augur/api/view/api.py index ba53557d9b..3e4504ce3e 100644 --- a/augur/api/view/api.py +++ b/augur/api/view/api.py @@ -10,29 +10,6 @@ def cache(file=None): return redirect(url_for('root', path=getSetting('caching'))) return redirect(url_for('root', path=toCacheFilepath(file))) - # API endpoint to clear server cache - # TODO: Add verification - @server.app.route('/cache/clear') - def clear_cache(): - try: - for f in os.listdir(getSetting('caching')): - os.remove(os.path.join(getSetting('caching'), f)) - return renderMessage("Cache Cleared", "Server cache was successfully cleared", redirect="/") - except Exception as err: - print(err) - return renderMessage("Error", "An error occurred while clearing server cache.", redirect="/", pause=5) - - # API endpoint to reload settings from disk - @server.app.route('/settings/reload') - def reload_settings(): - loadSettings() - return renderMessage("Settings Reloaded", "Server settings were successfully reloaded.", redirect="/", pause=5) - - # Request the frontend version as a JSON string - @server.app.route('/version') - def get_version(): - return jsonify(version) - @server.app.route('/account/repos/add/') @server.app.route('/account/repos/add') @login_required diff --git a/augur/api/view/augur_view.py b/augur/api/view/augur_view.py index a04b3a78f1..a4db664a90 100644 --- a/augur/api/view/augur_view.py +++ b/augur/api/view/augur_view.py @@ -2,10 +2,13 @@ from flask_login import LoginManager from .utils import * from .url_converters import * +from .init import logger # from .server import User -from augur.application.db.models import User +from augur.application.db.models import User, UserSessionToken from augur.application.db.session import DatabaseSession +from augur.api.routes import AUGUR_API_VERSION +from augur.api.util import get_bearer_token login_manager = LoginManager() @@ -30,7 +33,7 @@ def unsupported_method(error): @login_manager.unauthorized_handler def unauthorized(): - if "/api/unstable/" in str(request.url_rule): + if AUGUR_API_VERSION in str(request.url_rule): return jsonify({"status": "Login required"}) session["login_next"] = url_for(request.endpoint, **request.args) @@ -53,3 +56,14 @@ def load_user(user_id): return user + @login_manager.request_loader + def load_user_request(request): + token = get_bearer_token(request) + + with DatabaseSession(logger) as session: + + token = session.query(UserSessionToken).filter(UserSessionToken.token == token).first() + if token: + return token.user + + return None \ No newline at end of file diff --git a/augur/api/view/init.py b/augur/api/view/init.py index 556bcc93e5..1026ae3641 100644 --- a/augur/api/view/init.py +++ b/augur/api/view/init.py @@ -90,4 +90,3 @@ def init_logging(): global logger logger = logging.getLogger("augur view") logger.setLevel("DEBUG") - logging.basicConfig(filename="augur_view.log", filemode='a', format=format, level=logging.INFO, datefmt="%H:%M:%S") diff --git a/augur/api/view/routes.py b/augur/api/view/routes.py index 48ad649d62..bc8996f121 100644 --- a/augur/api/view/routes.py +++ b/augur/api/view/routes.py @@ -3,10 +3,11 @@ from sqlalchemy.orm.exc import NoResultFound from .utils import * from flask_login import login_user, logout_user, current_user, login_required -# from .server import User + from augur.application.db.models import User from .server import LoginException from augur.application.db.session import DatabaseSession +from augur.tasks.init.redis_connection import redis_connection as redis logger = logging.getLogger(__name__) @@ -183,6 +184,27 @@ def user_logout(): flash("You have been logged out") return redirect(url_for('root')) + """ ---------------------------------------------------------------- + default: + table: + This route performs external authorization for a user + """ + @app.route('/user/authorize') + @login_required + def authorize_user(): + client_id = request.args.get("client_id") + state = request.args.get("state") + response_type = request.args.get("response_type") + + if not client_id or response_type != "Code": + return renderMessage("Invalid Request", "Something went wrong. You may need to return to the previous application and make the request again.") + + # TODO get application from client id + + client = "get client" + + return render_module("authorization", application = client, state = state) + @server.app.route('/account/delete') @login_required def user_delete(): diff --git a/augur/application/db/models/augur_operations.py b/augur/application/db/models/augur_operations.py index ee8d67812a..015f6ad88c 100644 --- a/augur/application/db/models/augur_operations.py +++ b/augur/application/db/models/augur_operations.py @@ -306,7 +306,7 @@ def delete_user(self): session.delete(self) session.commit() - return {"status": "User deleted"}) + return {"status": "User deleted"} def update_user(self, **kwargs): @@ -570,6 +570,10 @@ class ClientToken(Base): } ) + # TODO ClientApplication table (Application ID: str/int, User ID: FK, Name: str, Redirect URL: str) + + # It should probably be 1:1 client token to application, so we only need one table + token = Column(String, primary_key=True, nullable=False) name = Column(String, nullable=False) expiration = Column(BigInteger) diff --git a/augur/templates/authorization.html b/augur/templates/authorization.html new file mode 100644 index 0000000000..bdb7b65663 --- /dev/null +++ b/augur/templates/authorization.html @@ -0,0 +1,41 @@ +

Authorize App

+ +

{{ application.name }} Sample App is requesting access to your account.

+

Authorizing this application will grant it access to the following:

+
    +
  • Username
  • +
  • Your repo groups
  • +
  • Information collected by Augur, both public and private:
  • +
  • +
      +
    • Issues
    • +
    • Pull requests
    • +
    • Comments
    • +
    • Commit logs
    • +
    +
  • +
+ +

By continuing, you authorize this access, and will be redirected to the following link:

+

{{ application.response_url }}

+Make sure you trust the application and this link before proceeding. + +
+ + + + +
+ + \ No newline at end of file diff --git a/augur/templates/login.html b/augur/templates/login.html index 72744d780a..c71d02d50f 100644 --- a/augur/templates/login.html +++ b/augur/templates/login.html @@ -51,12 +51,10 @@

User Login

- +
- +
From 1e9cffc4e45ad20c0fe2a934ed99833e14195b75 Mon Sep 17 00:00:00 2001 From: Ulincsys <28362836a@gmail.com> Date: Sun, 8 Jan 2023 22:05:35 -0600 Subject: [PATCH 058/150] Further integration testing and stability improvements Signed-off-by: Ulincsys <28362836a@gmail.com> --- augur/api/routes/config.py | 6 - augur/api/routes/user.py | 36 +-- augur/api/routes/util.py | 29 --- augur/api/util.py | 15 +- augur/api/view/api.py | 28 +- augur/api/view/augur_view.py | 10 +- augur/api/view/init.py | 57 +++- augur/api/view/routes.py | 134 +++++----- augur/api/view/utils.py | 6 +- augur/application/__init__.py | 13 + augur/application/cli/backend.py | 2 - augur/application/config.py | 5 + augur/application/db/models/__init__.py | 2 +- augur/application/db/models/augur_data.py | 6 + .../application/db/models/augur_operations.py | 244 +++++++++--------- .../versions/2_added_user_groups_and_login.py | 16 +- augur/application/util.py | 29 +++ augur/templates/authorization.html | 4 +- augur/templates/groups-table.html | 4 +- augur/templates/navbar.html | 6 +- augur/templates/repos-table.html | 21 +- augur/templates/settings.html | 7 +- augur/templates/status.html | 6 +- augur/util/repo_load_controller.py | 214 +++++++-------- 24 files changed, 481 insertions(+), 419 deletions(-) create mode 100644 augur/application/util.py diff --git a/augur/api/routes/config.py b/augur/api/routes/config.py index 3dad0105d1..08bb92d06b 100644 --- a/augur/api/routes/config.py +++ b/augur/api/routes/config.py @@ -28,12 +28,6 @@ def generate_upgrade_request(): return response, 426 def create_routes(server): - - @server.app.errorhandler(405) - def unsupported_method(error): - return jsonify({"status": "Unsupported method"}), 405 - - @server.app.route(f"/{AUGUR_API_VERSION}/config/get", methods=['GET', 'POST']) def get_config(): if not development and not request.is_secure: diff --git a/augur/api/routes/user.py b/augur/api/routes/user.py index f88b6d47a3..40ba3c9417 100644 --- a/augur/api/routes/user.py +++ b/augur/api/routes/user.py @@ -21,7 +21,7 @@ from augur.api.util import get_bearer_token from augur.api.util import get_client_token -from augur.application.db.models import User, UserRepo, UserGroup, UserSessionToken, ClientToken +from augur.application.db.models import User, UserRepo, UserGroup, UserSessionToken, ClientApplication from augur.application.config import get_development_flag from augur.tasks.init.redis_connection import redis_connection as redis @@ -33,8 +33,8 @@ from augur.api.routes import AUGUR_API_VERSION def api_key_required(fun): + # TODO Optionally rate-limit non authenticated users instead of rejecting requests def wrapper(*args, **kwargs): - # TODO check that API key is valid client_token = get_client_token() @@ -43,12 +43,11 @@ def wrapper(*args, **kwargs): session = Session() try: - session.query(ClientToken).filter(ClientToken.token == client_token).one() + kwargs["application"] = session.query(ClientApplication).filter(ClientApplication.id == client_token).one() return fun(*args, **kwargs) except NoResultFound: pass - # else: return error JSON return {"status": "Unauthorized client"} wrapper.__name__ = fun.__name__ @@ -58,7 +57,6 @@ def wrapper(*args, **kwargs): """ @app.route("/path") @api_key_required -@user_login_required def priviledged_function(): stuff """ @@ -73,11 +71,6 @@ def generate_upgrade_request(): return response, 426 def create_routes(server): - # TODO This functionality isn't specific to the User endpoints, and should be moved - @server.app.errorhandler(405) - def unsupported_method(error): - return jsonify({"status": "Unsupported method"}), 405 - @server.app.route(f"/{AUGUR_API_VERSION}/user/validate", methods=['POST']) def validate_user(): if not development and not request.is_secure: @@ -124,10 +117,9 @@ def user_authorize(): return jsonify({"status": "Validated", "code": code}) - @server.app.route(f"/{AUGUR_API_VERSION}/user/generate_session", methods=['POST']) + @server.app.route(f"/{AUGUR_API_VERSION}/user/session/generate", methods=['POST']) @api_key_required - def generate_session(): - # TODO Validate oauth token + def generate_session(application): code = request.args.get("code") username = redis.get(code) @@ -144,7 +136,7 @@ def generate_session(): expiration = int(time.time()) + seconds_to_expire session = Session() - user_session = UserSessionToken(token=user_session_token, user_id=user.user_id, expiration=expiration) + user_session = UserSessionToken(token=user_session_token, user_id=user.user_id, application_id = application.id, expiration=expiration) session.add(user_session) session.commit() @@ -229,7 +221,7 @@ def update_user(): return jsonify({"status": "Missing argument"}), 400 - @server.app.route(f"/{AUGUR_API_VERSION}/user/add_repo", methods=['GET', 'POST']) + @server.app.route(f"/{AUGUR_API_VERSION}/user/repo/add", methods=['GET', 'POST']) @login_required def add_user_repo(): if not development and not request.is_secure: @@ -243,7 +235,7 @@ def add_user_repo(): return jsonify(result) - @server.app.route(f"/{AUGUR_API_VERSION}/user/add_group", methods=['GET', 'POST']) + @server.app.route(f"/{AUGUR_API_VERSION}/user/group/add", methods=['GET', 'POST']) @login_required def add_user_group(): if not development and not request.is_secure: @@ -268,7 +260,7 @@ def remove_user_group(): return jsonify(result) - @server.app.route(f"/{AUGUR_API_VERSION}/user/add_org", methods=['GET', 'POST']) + @server.app.route(f"/{AUGUR_API_VERSION}/user/org/add", methods=['GET', 'POST']) @login_required def add_user_org(): if not development and not request.is_secure: @@ -282,7 +274,7 @@ def add_user_org(): return jsonify(result) - @server.app.route(f"/{AUGUR_API_VERSION}/user/remove_repo", methods=['GET', 'POST']) + @server.app.route(f"/{AUGUR_API_VERSION}/user/repo/remove", methods=['GET', 'POST']) @login_required def remove_user_repo(): if not development and not request.is_secure: @@ -296,8 +288,7 @@ def remove_user_repo(): return jsonify(result) - - @server.app.route(f"/{AUGUR_API_VERSION}/user/group_repos", methods=['GET', 'POST']) + @server.app.route(f"/{AUGUR_API_VERSION}/user/group/repos", methods=['GET', 'POST']) @login_required def group_repos(): """Select repos from a user group by name @@ -336,9 +327,7 @@ def group_repos(): return jsonify(result) - - - @server.app.route(f"/{AUGUR_API_VERSION}/user/group_repo_count", methods=['GET', 'POST']) + @server.app.route(f"/{AUGUR_API_VERSION}/user/group/repos/count", methods=['GET', 'POST']) @login_required def group_repo_count(): """Count repos from a user group by name @@ -365,7 +354,6 @@ def group_repo_count(): return jsonify(result) - @server.app.route(f"/{AUGUR_API_VERSION}/user/groups", methods=['GET', 'POST']) @login_required def get_user_groups(): diff --git a/augur/api/routes/util.py b/augur/api/routes/util.py index 57df12c7aa..e7e3102ff8 100644 --- a/augur/api/routes/util.py +++ b/augur/api/routes/util.py @@ -13,35 +13,6 @@ from augur.api.routes import AUGUR_API_VERSION -def get_all_repos(page=0, page_size=25, sort="repo_id", direction="ASC"): - - from augur.tasks.github.util.github_task_session import GithubTaskSession - from augur.util.repo_load_controller import RepoLoadController - - with GithubTaskSession(logger) as session: - - controller = RepoLoadController(session) - - result = controller.paginate_repos("all", page, page_size, sort, direction) - - return result - -def get_all_repos_count(): - - from augur.tasks.github.util.github_task_session import GithubTaskSession - from augur.util.repo_load_controller import RepoLoadController - - with GithubTaskSession(logger) as session: - - controller = RepoLoadController(session) - - result = controller.get_repo_count(source="all") - if result["status"] == "success": - return result["repos"] - - return result["status"] - - def create_routes(server): @server.app.route('/{}/repo-groups'.format(AUGUR_API_VERSION)) diff --git a/augur/api/util.py b/augur/api/util.py index 5a2eb7a703..b10c1f8c1c 100644 --- a/augur/api/util.py +++ b/augur/api/util.py @@ -83,14 +83,15 @@ def decorate(function): def get_token(token_type): auth = request.headers.get("Authorization") - tokens = auth.split(",") + if auth: + tokens = auth.split(",") - tokens = filter(lambda x: f"{token_type} " in x, tokens) + tokens = filter(lambda x: f"{token_type} " in x, tokens) - if len(tokens) != 1: - return None - - return tokens[0].replace(f"{token_type} ", "") + if len(tokens) != 1: + return None + + return tokens[0].replace(f"{token_type} ", "") """ Extract Bearer token from request header @@ -101,6 +102,6 @@ def get_bearer_token(): """ Extract Client token from request header """ -def get_bearer_token(): +def get_client_token(): return get_token("Client") \ No newline at end of file diff --git a/augur/api/view/api.py b/augur/api/view/api.py index 3e4504ce3e..c6f4c042e2 100644 --- a/augur/api/view/api.py +++ b/augur/api/view/api.py @@ -1,5 +1,6 @@ from flask import Flask, render_template, render_template_string, request, abort, jsonify, redirect, url_for, session, flash from flask_login import current_user, login_required +from augur.util.repo_load_controller import parse_org_url, parse_repo_url from .utils import * def create_routes(server): @@ -13,29 +14,20 @@ def cache(file=None): @server.app.route('/account/repos/add/') @server.app.route('/account/repos/add') @login_required - def av_add_user_repo(repo_url = None): - if not repo_url: + def av_add_user_repo(url = None): + # TODO finish UI and implement group adding + if not url: flash("Repo or org URL must not be empty") - elif current_user.try_add_url(repo_url): - flash("Successfully added repo or org") + elif result := parse_org_url(url): + current_user.add_org() + flash("Successfully added org") + elif result := parse_repo_url(url): + flash("Successfully added repo") else: flash("Could not add repo or org") return redirect(url_for("user_settings")) - - """ ---------------------------------------------------------------- - """ - @server.app.route('/requests/make/') - def make_api_request(request_endpoint): - do_cache = True - if request.headers.get("nocache") or request.args.get("nocache"): - do_cache = False - - data = requestJson(request_endpoint, do_cache) - if type(data) == tuple: - return jsonify({"request_error": data[1]}), 400 - return jsonify(data) - + """ ---------------------------------------------------------------- Locking request loop: This route will lock the current request until the diff --git a/augur/api/view/augur_view.py b/augur/api/view/augur_view.py index a4db664a90..0524b64ca2 100644 --- a/augur/api/view/augur_view.py +++ b/augur/api/view/augur_view.py @@ -25,11 +25,17 @@ def create_routes(server): # Code 404 response page, for pages not found @server.app.errorhandler(404) def page_not_found(error): + if AUGUR_API_VERSION in str(request.url_rule): + return jsonify({"status": "Not Found"}), 404 + return render_template('index.html', title='404', api_url=getSetting('serving')), 404 @server.app.errorhandler(405) def unsupported_method(error): - return renderMessage("405 - Method not supported", "The resource you are trying to access does not support the request method used"), 405 + if AUGUR_API_VERSION in str(request.url_rule): + return jsonify({"status": "Unsupported method"}), 405 + + return render_message("405 - Method not supported", "The resource you are trying to access does not support the request method used"), 405 @login_manager.unauthorized_handler def unauthorized(): @@ -58,7 +64,7 @@ def load_user(user_id): @login_manager.request_loader def load_user_request(request): - token = get_bearer_token(request) + token = get_bearer_token() with DatabaseSession(logger) as session: diff --git a/augur/api/view/init.py b/augur/api/view/init.py index 1026ae3641..fd98e12338 100644 --- a/augur/api/view/init.py +++ b/augur/api/view/init.py @@ -82,7 +82,62 @@ def compare_versions(old, new): settings = current_settings # default reports definition -reports = {'pull_request_reports': [{'url': 'pull_request_reports/average_commits_per_PR/', 'description': 'Average commits per pull request'}, {'url': 'pull_request_reports/average_comments_per_PR/', 'description': 'Average comments per pull request'}, {'url': 'pull_request_reports/PR_counts_by_merged_status/', 'description': 'Pull request counts by merged status'}, {'url': 'pull_request_reports/mean_response_times_for_PR/', 'description': 'Mean response times for pull requests'}, {'url': 'pull_request_reports/mean_days_between_PR_comments/', 'description': 'Mean days between pull request comments'}, {'url': 'pull_request_reports/PR_time_to_first_response/', 'description': 'Pull request time until first response'}, {'url': 'pull_request_reports/average_PR_events_for_closed_PRs/', 'description': 'Average pull request events for closed pull requests'}, {'url': 'pull_request_reports/Average_PR_duration/', 'description': 'Average pull request duration'}], 'contributor_reports': [{'url': 'contributor_reports/new_contributors_bar/', 'description': 'New contributors bar graph'}, {'url': 'contributor_reports/returning_contributors_pie_chart/', 'description': 'Returning contributors pie chart'}], 'contributor_reports_stacked': [{'url': 'contributor_reports/new_contributors_stacked_bar/', 'description': 'New contributors stacked bar chart'}, {'url': 'contributor_reports/returning_contributors_stacked_bar/', 'description': 'Returning contributors stacked bar chart'}]} +reports = { + "pull_request_reports":[ + { + "url":"average_commits_per_PR", + "description":"Average commits per pull request" + }, + { + "url":"average_comments_per_PR", + "description":"Average comments per pull request" + }, + { + "url":"PR_counts_by_merged_status", + "description":"Pull request counts by merged status" + }, + { + "url":"mean_response_times_for_PR", + "description":"Mean response times for pull requests" + }, + { + "url":"mean_days_between_PR_comments", + "description":"Mean days between pull request comments" + }, + { + "url":"PR_time_to_first_response", + "description":"Pull request time until first response" + }, + { + "url":"average_PR_events_for_closed_PRs", + "description":"Average pull request events for closed pull requests" + }, + { + "url":"Average_PR_duration", + "description":"Average pull request duration" + } + ], + "contributor_reports":[ + { + "url":"new_contributors_bar", + "description":"New contributors bar graph" + }, + { + "url":"returning_contributors_pie_chart", + "description":"Returning contributors pie chart" + } + ], + "contributor_reports_stacked":[ + { + "url":"new_contributors_stacked_bar", + "description":"New contributors stacked bar chart" + }, + { + "url":"returning_contributors_stacked_bar", + "description":"Returning contributors stacked bar chart" + } + ] +} # Initialize logging def init_logging(): diff --git a/augur/api/view/routes.py b/augur/api/view/routes.py index bc8996f121..d3c55ef289 100644 --- a/augur/api/view/routes.py +++ b/augur/api/view/routes.py @@ -4,13 +4,17 @@ from .utils import * from flask_login import login_user, logout_user, current_user, login_required -from augur.application.db.models import User +from augur.application.db.models import User, Repo from .server import LoginException from augur.application.db.session import DatabaseSession from augur.tasks.init.redis_connection import redis_connection as redis +from augur.application.util import * +from augur.application.config import AugurConfig logger = logging.getLogger(__name__) +with DatabaseSession(logger) as db_session: + config = AugurConfig(logger, db_session) # ROUTES ----------------------------------------------------------------------- @@ -52,33 +56,37 @@ def logo(brand=None): @server.app.route('/repos/views/table') def repo_table_view(): query = request.args.get('q') - page = request.args.get('p') + try: + page = int(request.args.get('p') or 0) + except: + page = 1 + sorting = request.args.get('s') rev = request.args.get('r') + if rev is not None: if rev == "False": rev = False elif rev == "True": rev = True - else: - rev = False + + direction = "DESC" if rev else "ASC" + + pagination_offset = config.get_value("frontend", "pagination_offset") if current_user.is_authenticated: - data = requestJson("repos", cached = False) - user_repo_ids = current_user.query_repos() - user_repos = [] - for repo in data: - if repo["repo_id"] in user_repo_ids: - user_repos.append(repo) + data = current_user.get_repos(page = page, sort = sorting, direction = direction) - data = user_repos or None + page_count = (current_user.get_repo_count() or 0) // pagination_offset else: - data = requestJson("repos") - + data = get_all_repos(page = page, sort = sorting, direction = direction) + page_count = (get_all_repos_count() or 0) // pagination_offset + #if not cacheFileExists("repos.json"): # return renderLoading("repos/views/table", query, "repos.json") - return renderRepos("table", query, data, sorting, rev, page, True) + # return renderRepos("table", query, data, sorting, rev, page, True) + return render_module("repos-table", title="Repos", repos=data, query_key=query, activePage=page, pages=page_count, offset=pagination_offset, PS="repo_table_view", reverse = rev, sorting = sorting) """ ---------------------------------------------------------------- card: @@ -87,32 +95,34 @@ def repo_table_view(): @server.app.route('/repos/views/card') def repo_card_view(): query = request.args.get('q') - return renderRepos("card", query, requestJson("repos"), filter = True) + count = get_all_repos_count() + data = get_all_repos(page_size=count) + return renderRepos("card", query, data, filter = True) """ ---------------------------------------------------------------- groups: This route returns the groups table view, listing all the current groups in the backend """ - @server.app.route('/groups') - @server.app.route('/groups/') - def repo_groups_view(group=None): - query = request.args.get('q') - page = request.args.get('p') - - if(group is not None): - query = group - - if(query is not None): - buffer = [] - data = requestJson("repos") - for repo in data: - if query == str(repo["repo_group_id"]) or query in repo["rg_name"]: - buffer.append(repo) - return renderRepos("table", query, buffer, page = page, pageSource = "repo_groups_view") - else: - groups = requestJson("repo-groups") - return render_template('index.html', body="groups-table", title="Groups", groups=groups, query_key=query, api_url=getSetting('serving')) + # @server.app.route('/groups') + # @server.app.route('/groups/') + # def repo_groups_view(group=None): + # query = request.args.get('q') + # page = request.args.get('p') + + # if(group is not None): + # query = group + + # if(query is not None): + # buffer = [] + # data = requestJson("repos") + # for repo in data: + # if query == str(repo["repo_group_id"]) or query in repo["rg_name"]: + # buffer.append(repo) + # return renderRepos("table", query, buffer, page = page, pageSource = "repo_groups_view") + # else: + # groups = requestJson("repo-groups") + # return render_template('index.html', body="groups-table", title="Groups", groups=groups, query_key=query, api_url=getSetting('serving')) """ ---------------------------------------------------------------- status: @@ -189,7 +199,7 @@ def user_logout(): table: This route performs external authorization for a user """ - @app.route('/user/authorize') + @server.app.route('/user/authorize') @login_required def authorize_user(): client_id = request.args.get("client_id") @@ -197,11 +207,10 @@ def authorize_user(): response_type = request.args.get("response_type") if not client_id or response_type != "Code": - return renderMessage("Invalid Request", "Something went wrong. You may need to return to the previous application and make the request again.") + return render_message("Invalid Request", "Something went wrong. You may need to return to the previous application and make the request again.") # TODO get application from client id - - client = "get client" + client = ClientApplication.get_by_id(client_id) return render_module("authorization", application = client, state = state) @@ -243,19 +252,9 @@ def user_settings(): def repo_repo_view(id): # For some reason, there is no reports definition (shouldn't be possible) if reports is None: - return renderMessage("Report Definitions Missing", "You requested a report for a repo on this instance, but a definition for the report layout was not found.") - data = requestJson("repos") - repo = {} - # Need to convert the repo id parameter to int so it's comparable - try: - id = int(id) - except: - pass - # Finding the report object in the data so the name is accessible on the page - for item in data: - if item['repo_id'] == id: - repo = item - break + return render_message("Report Definitions Missing", "You requested a report for a repo on this instance, but a definition for the report layout was not found.") + + repo = Repo.get_by_id(id) return render_module("repo-info", reports=reports.keys(), images=reports, title="Repo", repo=repo, repo_id=id) @@ -266,16 +265,17 @@ def repo_repo_view(id): is currently defined as the repository table view """ @server.app.route('/user/group/') - def user_group_view(group): - params = {} + @login_required + def user_group_view(): + group = request.args.get("group") - # NOT IMPLEMENTED - # query = request.args.get('q') + if not group: + return render_message("No Group Specified", "You must specify a group to view this page.") try: - params["page"] = int(request.args.get('p')) + params["page"] = int(request.args.get('p') or 0) except: - pass + params["page"] = 1 if sort := request.args.get('s'): params["sort"] = sort @@ -283,22 +283,26 @@ def user_group_view(group): rev = request.args.get('r') if rev is not None: if rev == "False": + rev = False params["direction"] = "ASC" elif rev == "True": + rev = True params["direction"] = "DESC" - - if current_user.is_authenticated: - data = current_user.select_group(group, **params) - if not data: - return renderMessage("Error Loading Group", "Either the group you requestion does not exist, or an unspecified error occurred.") - else: - return renderMessage("Authentication Required", "You must be logged in to view this page.") + pagination_offset = config.get_value("frontend", "pagination_offset") + + data = current_user.get_group_repos(group, **params) + page_count = current_user.get_group_repo_count(group) or 0 + page_count //= pagination_offset + + if not data: + return render_message("Error Loading Group", "Either the group you requested does not exist, or an unspecified error occurred.") #if not cacheFileExists("repos.json"): # return renderLoading("repos/views/table", query, "repos.json") - return renderRepos("table", None, data, sort, rev, params.get("page"), True) + # return renderRepos("table", None, data, sort, rev, params.get("page"), True) + return render_module("repos-table", title="Repos", repos=data, query_key=query, activePage=params["page"], pages=page_count, offset=pagination_offset, PS="user_group_view", reverse = rev, sorting = params["sort"]) """ ---------------------------------------------------------------- Admin dashboard: diff --git a/augur/api/view/utils.py b/augur/api/view/utils.py index 9ae6137ad3..4239f0625a 100644 --- a/augur/api/view/utils.py +++ b/augur/api/view/utils.py @@ -317,7 +317,9 @@ def requestReports(repo_id): # Where should the downloaded image be stored (in cache) filename = toCacheFilename(f"{image['url']}?repo_id={repo_id}") # Where are we downloading the image from - image_url = f"{getSetting('serving')}/{image['url']}?repo_id={repo_id}" + image_url = url_for(image['url'], repo_id = repo_id) + # f"{getSetting('serving')}/{image['url']}?repo_id={repo_id}" + # Add a request for this image to the thread pool using the download function thread_pool.submit(download, image_url, connection_mgr, filename, reportImages, image['id'], repo_id) @@ -411,7 +413,7 @@ def renderRepos(view, query, data, sorting = None, rev = False, page = None, fil Renders a simple page with the given message information, and optional page title and redirect """ -def renderMessage(messageTitle, messageBody, title = None, redirect = None, pause = None): +def render_message(messageTitle, messageBody = None, title = None, redirect = None, pause = None): return render_module("notice", messageTitle=messageTitle, messageBody=messageBody, title=title, redirect=redirect, pause=pause) """ ---------------------------------------------------------------- diff --git a/augur/application/__init__.py b/augur/application/__init__.py index e69de29bb2..9091d6232a 100644 --- a/augur/application/__init__.py +++ b/augur/application/__init__.py @@ -0,0 +1,13 @@ +def requires_db_session(logger): + def inner_decorator(fun): + def wrapper(*args, **kwargs): + + from augur.application.db.session import DatabaseSession + + # create DB session + with DatabaseSession(logger) as session: + + return fun(session, *args, **kwargs) + + return wrapper + return inner_decorator diff --git a/augur/application/cli/backend.py b/augur/application/cli/backend.py index b8b6559abf..5dc1dfb17a 100644 --- a/augur/application/cli/backend.py +++ b/augur/application/cli/backend.py @@ -35,7 +35,6 @@ def cli(): @cli.command("start") @click.option("--disable-collection", is_flag=True, default=False, help="Turns off data collection workers") @click.option("--development", is_flag=True, default=False, help="Enable development mode, implies --disable-collection") -@click.option("--development", is_flag=True, default=False, help="Enable development mode, implies --disable-collection") @click.option('--port') @test_connection @test_db_connection @@ -53,7 +52,6 @@ def start(disable_collection, development, port): raise e if development: - disable_collection = True os.environ["AUGUR_DEV"] = "1" logger.info("Starting in development mode") diff --git a/augur/application/config.py b/augur/application/config.py index 58c2fa3eea..b433c66240 100644 --- a/augur/application/config.py +++ b/augur/application/config.py @@ -176,6 +176,11 @@ def get_value(self, section_name: str, setting_name: str) -> Optional[Any]: Returns: The value from config if found, and None otherwise """ + + # TODO temporary until added to the DB schema + if section_name == "frontend" and setting_name == "pagination_offset": + return 25 + try: query = self.session.query(Config).filter(Config.section_name == section_name, Config.setting_name == setting_name) config_setting = execute_session_query(query, 'one') diff --git a/augur/application/db/models/__init__.py b/augur/application/db/models/__init__.py index 5606168f64..d76acf2157 100644 --- a/augur/application/db/models/__init__.py +++ b/augur/application/db/models/__init__.py @@ -102,5 +102,5 @@ UserRepo, UserGroup, UserSessionToken, - ClientToken + ClientApplication ) diff --git a/augur/application/db/models/augur_data.py b/augur/application/db/models/augur_data.py index 5dd5618623..178761ebdf 100644 --- a/augur/application/db/models/augur_data.py +++ b/augur/application/db/models/augur_data.py @@ -22,6 +22,7 @@ from sqlalchemy.orm import relationship from sqlalchemy.sql import text from augur.application.db.models.base import Base +from augur.application import requires_db_session metadata = Base.metadata @@ -815,7 +816,12 @@ class Repo(Base): repo_group = relationship("RepoGroup") user_repo = relationship("UserRepo") + @staticmethod + @requires_db_session + def get_by_id(session, repo_id): + return session.query(Repo).filter(Repo.repo_id == repo_id).first() + class RepoTestCoverage(Base): __tablename__ = "repo_test_coverage" __table_args__ = {"schema": "augur_data"} diff --git a/augur/application/db/models/augur_operations.py b/augur/application/db/models/augur_operations.py index 015f6ad88c..72402de202 100644 --- a/augur/application/db/models/augur_operations.py +++ b/augur/application/db/models/augur_operations.py @@ -2,19 +2,25 @@ from sqlalchemy import BigInteger, SmallInteger, Column, Index, Integer, String, Table, text, UniqueConstraint, Boolean, ForeignKey from sqlalchemy.dialects.postgresql import TIMESTAMP, UUID from sqlalchemy.orm.exc import NoResultFound +from sqlalchemy.orm import relationship from werkzeug.security import generate_password_hash, check_password_hash import logging -logger = logging.getLogger(__name__) +from augur.application.db.models.base import Base +logger = logging.getLogger(__name__) +def get_session(): + global session -from augur.application.db.models.base import Base -from sqlalchemy.orm import relationship + if "session" not in globals(): + from augur.application.db.session import DatabaseSession + session = DatabaseSession(logger) + return session + metadata = Base.metadata - t_all = Table( "all", metadata, @@ -201,6 +207,7 @@ class User(Base): groups = relationship("UserGroup") tokens = relationship("UserSessionToken") + applications = relationship("ClientApplication") _is_authenticated = False _is_active = True @@ -239,8 +246,6 @@ def get_id(self): def validate(self, password) -> bool: - from augur.application.db.session import DatabaseSession - if not password: return False @@ -253,132 +258,122 @@ def get_user(username: str): if not username: return None - from augur.application.db.session import DatabaseSession + local_session = get_session() - with DatabaseSession(logger) as session: - try: - user = session.query(User).filter(User.login_name == username).one() - return user - except NoResultFound: - return None + try: + user = local_session.query(User).filter(User.login_name == username).one() + return user + except NoResultFound: + return None @staticmethod def create_user(username: str, password: str, email: str, first_name:str, last_name:str, admin=False): - from augur.application.db.session import DatabaseSession - if username is None or password is None or email is None or first_name is None or last_name is None: return {"status": "Missing field"} + local_session = get_session() - with DatabaseSession(logger) as session: - - user = session.query(User).filter(User.login_name == username).first() - if user is not None: - return {"status": "A User already exists with that username"} + user = local_session.query(User).filter(User.login_name == username).first() + if user is not None: + return {"status": "A User already exists with that username"} - emailCheck = session.query(User).filter(User.email == email).first() - if emailCheck is not None: - return {"status": "A User already exists with that email"} + emailCheck = local_session.query(User).filter(User.email == email).first() + if emailCheck is not None: + return {"status": "A User already exists with that email"} - try: - user = User(login_name = username, login_hashword = generate_password_hash(password), email = email, first_name = first_name, last_name = last_name, tool_source="User API", tool_version=None, data_source="API", admin=admin) - session.add(user) - session.commit() - return {"status": "Account successfully created"} - except AssertionError as exception_message: - return {"Error": f"{exception_message}."} + try: + user = User(login_name = username, login_hashword = generate_password_hash(password), email = email, first_name = first_name, last_name = last_name, tool_source="User API", tool_version=None, data_source="API", admin=admin) + local_session.add(user) + local_session.commit() + return {"status": "Account successfully created"} + except AssertionError as exception_message: + return {"Error": f"{exception_message}."} def delete_user(self): - - from augur.application.db.session import DatabaseSession - with DatabaseSession(logger) as session: + local_session = get_session() - for group in self.groups: - user_repos_list = group.repos + for group in self.groups: + user_repos_list = group.repos - for user_repo_entry in user_repos_list: - session.delete(user_repo_entry) + for user_repo_entry in user_repos_list: + local_session.delete(user_repo_entry) - session.delete(group) + local_session.delete(group) - session.delete(self) - session.commit() + local_session.delete(self) + local_session.commit() - return {"status": "User deleted"} + return {"status": "User deleted"} def update_user(self, **kwargs): - from augur.application.db.session import DatabaseSession + local_session = get_session() - with DatabaseSession(session) as session: - - valid_kwargs = ["email", "password", "username"] + valid_kwargs = ["email", "password", "username"] - kwarg_present = False - for value in valid_kwargs: + kwarg_present = False + for value in valid_kwargs: - if value in kwargs: - if kwarg_present: - return {"status": "Please pass an email, password, or username, not multiple"} + if value in kwargs: + if kwarg_present: + return {"status": "Please pass an email, password, or username, not multiple"} - kwarg_present = True + kwarg_present = True - if "email" in kwargs: + if "email" in kwargs: - existing_user = session.query(User).filter(User.email == kwargs["email"]).one() - if existing_user is not None: - return jsonify({"status": "Already an account with this email"}) + existing_user = local_session.query(User).filter(User.email == kwargs["email"]).one() + if existing_user is not None: + return jsonify({"status": "Already an account with this email"}) - user.email = kwargs["email"] - session.commit() - return jsonify({"status": "Email Updated"}) + user.email = kwargs["email"] + local_session.commit() + return jsonify({"status": "Email Updated"}) - if "password" in kwargs: - user.login_hashword = generate_password_hash(kwargs["password"]) - session.commit() - return jsonify({"status": "Password Updated"}) + if "password" in kwargs: + user.login_hashword = generate_password_hash(kwargs["password"]) + local_session.commit() + return jsonify({"status": "Password Updated"}) - if "username" in kwargs: - existing_user = session.query(User).filter(User.login_name == kwargs["username"]).one() - if existing_user is not None: - return jsonify({"status": "Username already taken"}) + if "username" in kwargs: + existing_user = local_session.query(User).filter(User.login_name == kwargs["username"]).one() + if existing_user is not None: + return jsonify({"status": "Username already taken"}) - user.login_name = kwargs["username"] - session.commit() - return jsonify({"status": "Username Updated"}) + user.login_name = kwargs["username"] + local_session.commit() + return jsonify({"status": "Username Updated"}) - return jsonify({"status": "Missing argument"}), 400 + return jsonify({"status": "Missing argument"}), 400 def add_group(self, group_name): - from augur.tasks.github.util.github_task_session import GithubTaskSession from augur.util.repo_load_controller import RepoLoadController if group_name == "default": return {"status": "Reserved Group Name"} - with GithubTaskSession(logger) as session: + local_session = get_session() - repo_load_controller = RepoLoadController(gh_session=session) + repo_load_controller = RepoLoadController(gh_session=local_session) - result = repo_load_controller.add_user_group(self.user_id, group_name) + result = repo_load_controller.add_user_group(self.user_id, group_name) - return result + return result def remove_group(self, group_name): - from augur.tasks.github.util.github_task_session import GithubTaskSession from augur.util.repo_load_controller import RepoLoadController - with GithubTaskSession(logger) as session: + local_session = get_session() - repo_load_controller = RepoLoadController(gh_session=session) + repo_load_controller = RepoLoadController(gh_session=local_session) - result = repo_load_controller.remove_user_group(self.user_id, group_name) + result = repo_load_controller.remove_user_group(self.user_id, group_name) - return result + return result def add_repo(self, group_name, repo_url): @@ -395,16 +390,15 @@ def add_repo(self, group_name, repo_url): def remove_repo(self, group_name, repo_id): - from augur.tasks.github.util.github_task_session import GithubTaskSession from augur.util.repo_load_controller import RepoLoadController - with GithubTaskSession(logger) as session: + local_session = get_session() - repo_load_controller = RepoLoadController(gh_session=session) + repo_load_controller = RepoLoadController(gh_session=local_session) - result = repo_load_controller.remove_frontend_repo(repo_id, self.user_id, group_name) + result = repo_load_controller.remove_frontend_repo(repo_id, self.user_id, group_name) - return result + return result def add_org(self, group_name, org_url): @@ -421,16 +415,15 @@ def add_org(self, group_name, org_url): def get_groups(self): - from augur.tasks.github.util.github_task_session import GithubTaskSession from augur.util.repo_load_controller import RepoLoadController - with GithubTaskSession(logger) as session: + local_session = get_session() - controller = RepoLoadController(session) - - user_groups = controller.get_user_groups(user.user_id) + controller = RepoLoadController(local_session) + + user_groups = controller.get_user_groups(self.user_id) - return {"groups": user_groups} + return {"groups": user_groups} def get_group_names(self): @@ -442,72 +435,58 @@ def get_group_names(self): def query_repos(self): - from augur.application.db.session import DatabaseSession from augur.application.db.models.augur_data import Repo - with DatabaseSession(logger) as session: + local_session = get_session() - return [repo.repo_id for repo in session.query(Repo).all()] + return [repo.repo_id for repo in local_session.query(Repo).all()] - def get_repos(self, group_name, page=0, page_size=25, sort="repo_id", direction="ASC"): + def get_repos(self, page=0, page_size=25, sort="repo_id", direction="ASC"): - from augur.tasks.github.util.github_task_session import GithubTaskSession from augur.util.repo_load_controller import RepoLoadController - with GithubTaskSession(logger) as session: - - with RepoLoadController(session) as controller: + local_session = get_session() - result = controller.paginate_repos("user", page, page_size, sort, direction, user=self, group_name=group_name) + result = RepoLoadController(local_session).paginate_repos("user", page, page_size, sort, direction, user=self) - return result + return result def get_repo_count(self): - from augur.tasks.github.util.github_task_session import GithubTaskSession from augur.util.repo_load_controller import RepoLoadController - with GithubTaskSession(logger) as session: + local_session = get_session() - controller = RepoLoadController(session) + controller = RepoLoadController(local_session) - result = controller.get_repo_count(source="user", user=self) - if result["status"] == "success": - return result["repos"] + result = controller.get_repo_count(source="user", user=self) - return result["status"] + return result def get_group_repos(self, group_name, page=0, page_size=25, sort="repo_id", direction="ASC"): - from augur.tasks.github.util.github_task_session import GithubTaskSession from augur.util.repo_load_controller import RepoLoadController - with GithubTaskSession(logger) as session: - - with RepoLoadController(session) as controller: + local_session = get_session() - result = controller.paginate_repos("group", page, page_size, sort, direction, user=self, group_name=group_name) + result = RepoLoadController(local_session).paginate_repos("group", page, page_size, sort, direction, user=self, group_name=group_name) - return result + return result def get_group_repo_count(self, group_name): - from augur.tasks.github.util.github_task_session import GithubTaskSession from augur.util.repo_load_controller import RepoLoadController - with GithubTaskSession(logger) as session: - - controller = RepoLoadController(session) + local_session = get_session() - result = controller.get_repo_count(source="group", group_name=group_name, user=self) - if result["status"] == "success": - return result["repos"] + controller = RepoLoadController(local_session) - return result["status"] + result = controller.get_repo_count(source="group", group_name=group_name, user=self) + return result @@ -558,25 +537,32 @@ class UserSessionToken(Base): token = Column(String, primary_key=True, nullable=False) user_id = Column(ForeignKey("augur_operations.users.user_id", name="user_session_token_user_id_fkey")) expiration = Column(BigInteger) + application_id = Column(ForeignKey("augur_operations.client_applications.id", name="user_session_token_application_id_fkey"), nullable=False) user = relationship("User") + application = relationship("ClientApplication") -class ClientToken(Base): - __tablename__ = "client_tokens" +class ClientApplication(Base): + __tablename__ = "client_applications" __table_args__ = ( { "schema": "augur_operations" } ) - # TODO ClientApplication table (Application ID: str/int, User ID: FK, Name: str, Redirect URL: str) + id = Column(String, primary_key=True, nullable=False) + user_id = Column(ForeignKey("augur_operations.users.user_id", name="client_application_user_id_fkey"), nullable=False) + name = Column(String, nullable=False) + redirect_url = Column(String, nullable=False) - # It should probably be 1:1 client token to application, so we only need one table + user = relationship("User") - token = Column(String, primary_key=True, nullable=False) - name = Column(String, nullable=False) - expiration = Column(BigInteger) + @staticmethod + def get_by_id(client_id): + local_session = get_session() + return local_session.query(ClientApplication).filter(ClientApplication.id == client_id).first() + diff --git a/augur/application/schema/alembic/versions/2_added_user_groups_and_login.py b/augur/application/schema/alembic/versions/2_added_user_groups_and_login.py index 94abb429cc..d079f57ac4 100644 --- a/augur/application/schema/alembic/versions/2_added_user_groups_and_login.py +++ b/augur/application/schema/alembic/versions/2_added_user_groups_and_login.py @@ -102,17 +102,23 @@ def upgrade(): user_repo_insert = sa.sql.text(f"""INSERT INTO "augur_operations"."user_repos" ("group_id", "repo_id") VALUES ({group_id}, {repo_id});""") result = session.execute_sql(user_repo_insert) - op.create_table('client_tokens', + op.create_table('client_applications', + sa.Column('id', sa.String(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), sa.Column('name', sa.String(), nullable=False), - sa.Column('token', sa.String(), nullable=False), - sa.Column('expiration', sa.BigInteger(), nullable=True), - sa.PrimaryKeyConstraint('token'), + sa.Column('redirect_url', sa.String(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['augur_operations.users.user_id'], name='client_application_user_id_fkey'), + sa.PrimaryKeyConstraint('id'), schema='augur_operations' ) + op.create_table('user_session_tokens', sa.Column('token', sa.String(), nullable=False), sa.Column('user_id', sa.Integer(), nullable=False), sa.Column('expiration', sa.BigInteger(), nullable=True), + sa.Column('application_id', sa.String(), nullable=True), + + sa.ForeignKeyConstraint(['application_id'], ['augur_operations.client_applications.id'], name='user_session_token_application_id_fkey'), sa.ForeignKeyConstraint(['user_id'], ['augur_operations.users.user_id'], name='user_session_token_user_fk'), sa.PrimaryKeyConstraint('token'), schema='augur_operations' @@ -183,6 +189,6 @@ def downgrade(): session.execute_sql(sa.sql.text(query_text)) op.drop_table('user_session_tokens', schema='augur_operations') - op.drop_table('client_tokens', schema='augur_operations') + op.drop_table('client_applications', schema='augur_operations') # ### end Alembic commands ### diff --git a/augur/application/util.py b/augur/application/util.py new file mode 100644 index 0000000000..2d606804a6 --- /dev/null +++ b/augur/application/util.py @@ -0,0 +1,29 @@ +import logging + +from augur.tasks.github.util.github_task_session import GithubTaskSession +from augur.util.repo_load_controller import RepoLoadController + +logger = logging.getLogger(__name__) + +def get_all_repos(page=0, page_size=25, sort="repo_id", direction="ASC"): + + with GithubTaskSession(logger) as session: + + controller = RepoLoadController(session) + + result = controller.paginate_repos("all", page, page_size, sort, direction) + + return result + +def get_all_repos_count(): + + with GithubTaskSession(logger) as session: + + controller = RepoLoadController(session) + + result = controller.get_repo_count(source="all") + + return result + + + diff --git a/augur/templates/authorization.html b/augur/templates/authorization.html index bdb7b65663..929ee857f7 100644 --- a/augur/templates/authorization.html +++ b/augur/templates/authorization.html @@ -17,10 +17,10 @@

Authorize App

By continuing, you authorize this access, and will be redirected to the following link:

-

{{ application.response_url }}

+

{{ application.redirect_url }}

Make sure you trust the application and this link before proceeding. -
+ diff --git a/augur/templates/groups-table.html b/augur/templates/groups-table.html index abe57a5774..e5ddf094fa 100644 --- a/augur/templates/groups-table.html +++ b/augur/templates/groups-table.html @@ -1,4 +1,4 @@ -{% if groups %} +{# % if groups %}
@@ -30,4 +30,4 @@

Your search did not match any results

{% else %}

Unable to load group information

-{% endif %} +{% endif % #} diff --git a/augur/templates/navbar.html b/augur/templates/navbar.html index e84c639a9b..fe498548a9 100644 --- a/augur/templates/navbar.html +++ b/augur/templates/navbar.html @@ -18,7 +18,7 @@ {% if current_user.is_authenticated %} {% for repo in repos %} - + - + {# #} @@ -71,8 +71,8 @@
{{loop.index + (activePage - 1) * offset}}{{loop.index + (activePage) * offset}} {{ repo.repo_name }}{{ repo.rg_name }}{{ repo.rg_name }}TODO {{ repo.commits_all_time|int }} {{ repo.issues_all_time|int }}
+ {% endfor %} {% else %} -

No repos selected

+

No groups created

{% endif %}
@@ -114,7 +115,7 @@

Add Repos

event.preventDefault(); var input = document.getElementById("repo_url"); loadingModal.show(); - window.location.replace("{{ url_for('add_user_repo') }}/" + input.value); + window.location.replace("{{ url_for('av_add_user_repo') }}/" + input.value); }); function formError(event, message) { diff --git a/augur/templates/status.html b/augur/templates/status.html index 80a9ff05c9..02b62ed950 100644 --- a/augur/templates/status.html +++ b/augur/templates/status.html @@ -52,7 +52,7 @@

Commits

- - - - - - Dasboard - Augur View - - - - - -
-
-
-
- Dashboard -
-
- -
- -
- {# Start dashboard content #} -
-

Stats

- {# Start content card #} -
-
- {# Start form body #} - - {% for section in sections %} -
-
-
{{ section.title }}
-
- {% for setting in section.settings %} -
-
- - -
{{ setting.description or "No description available" }}
-
-
- {% endfor %} -
- {% endfor %} - {#
-
- -
-
#} - -
-
-

User Accounts

- {# Start content card #} -
-
-
- {% for section in sections %} -
-
-
{{ section.title }}
-
- {% for setting in section.settings %} -
-
- - -
{{ setting.description or "No description available" }}
-
-
- {% endfor %} -
- {% endfor %} - {#
-
- -
-
#} -
-
-
-

Configuration

- {# Start content card #} -
-
-
- {% for section in config.items() %} -
-
-
{{ section[0] }}
-
- {% for setting in section[1].items() %} -
-
- - -
No description available
-
-
- {% endfor %} -
- {% endfor %} -
-
- -
-
-
-
-
-
-
-
- - - - diff --git a/augur/templates/authorization.html b/augur/templates/authorization.html deleted file mode 100644 index 929ee857f7..0000000000 --- a/augur/templates/authorization.html +++ /dev/null @@ -1,41 +0,0 @@ -

Authorize App

- -

{{ application.name }} Sample App is requesting access to your account.

-

Authorizing this application will grant it access to the following:

-
    -
  • Username
  • -
  • Your repo groups
  • -
  • Information collected by Augur, both public and private:
  • -
  • -
      -
    • Issues
    • -
    • Pull requests
    • -
    • Comments
    • -
    • Commit logs
    • -
    -
  • -
- -

By continuing, you authorize this access, and will be redirected to the following link:

-

{{ application.redirect_url }}

-Make sure you trust the application and this link before proceeding. - -
- - - -
- - - \ No newline at end of file diff --git a/augur/templates/first-time.html b/augur/templates/first-time.html deleted file mode 100644 index c8eb284da8..0000000000 --- a/augur/templates/first-time.html +++ /dev/null @@ -1,211 +0,0 @@ -{# https://www.bootdey.com/snippets/view/dark-profile-settings #} - - - - - - - - - - - - - - -
-
- {# Start sidebar #} -
-
-
- -
- -
-
- {# Start form body #} -
-
-
-
- {% for section in sections %} -
-
-
{{ section.title }}
-
- {% for setting in section.settings %} -
-
- - -
{{ setting.description }}
-
-
- {% endfor %} -
- {% endfor %} -
-
-
Gunicorn Settings
-
-
-
-
{{ gunicorn_placeholder }}
-
-
-
-
-
- -
-
-
-
-
-
-
-
- - - - - - - diff --git a/augur/templates/groups-table.html b/augur/templates/groups-table.html deleted file mode 100644 index e5ddf094fa..0000000000 --- a/augur/templates/groups-table.html +++ /dev/null @@ -1,33 +0,0 @@ -{# % if groups %} -
- - - - - - - - - - - - - {% for group in groups %} - - - - - - - - - - {% endfor %} - -
#Group NameGroup IDDescriptionLast ModifiedData Collection Date
{{loop.index}}{{ group.rg_name }}{{ group.repo_group_id }}{{ group.rg_description }}{{ group.rg_last_modified }}{{ group.data_collection_date }}TODO
-
-{% elif query_key %} -

Your search did not match any results

-{% else %} -

Unable to load group information

-{% endif % #} diff --git a/augur/templates/index.html b/augur/templates/index.html deleted file mode 100644 index bf73b40f16..0000000000 --- a/augur/templates/index.html +++ /dev/null @@ -1,67 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - {% if title %} - {{title}} - Augur View - {% else %} - Augur View - {% endif %} - - {% if redirect %} - - {% endif %} - - - - - - - {% include 'notifications.html' %} - - {% include 'navbar.html' %} - -
- {% if invalid %} -

Invalid API URL

-

The API URL [{{ api_url or 'unspecified'}}] is invalid

- {% elif body %} - {% include '%s.html' % body ignore missing %} - {% else %} -

404 - Page Not Found

-

The page you were looking for isn't here, try clicking one of the navigation links above

- {% endif %} -
- - - - diff --git a/augur/templates/loading.html b/augur/templates/loading.html deleted file mode 100644 index 052af79eab..0000000000 --- a/augur/templates/loading.html +++ /dev/null @@ -1,14 +0,0 @@ -{% if not d %} -

Uh oh, Something went wrong!

-

You were sent to this page because we were loading something for you, but we didn't catch your destination.

-

Go back to the previous page and try again. If that doesn't help, submit an issue to https://github.com/chaoss/augur .

-{% else %} - -

Give us a moment!

-

We are retreiving some data for you, and it may take up to a few seconds to load.

-

If you aren't redirected in a few seconds, go back to the previous page and try again.

- -

Redirecting to: {{url_for('root', path=d)}}

-{% endif %} diff --git a/augur/templates/login.html b/augur/templates/login.html deleted file mode 100644 index c71d02d50f..0000000000 --- a/augur/templates/login.html +++ /dev/null @@ -1,155 +0,0 @@ -
-
- -
-
- - - - \ No newline at end of file diff --git a/augur/templates/navbar.html b/augur/templates/navbar.html deleted file mode 100644 index fe498548a9..0000000000 --- a/augur/templates/navbar.html +++ /dev/null @@ -1,67 +0,0 @@ - diff --git a/augur/templates/notice.html b/augur/templates/notice.html deleted file mode 100644 index 46ed7ead66..0000000000 --- a/augur/templates/notice.html +++ /dev/null @@ -1,6 +0,0 @@ -{% if messageTitle %} -

{{messageTitle}}

-{% endif %} -{% if messageBody %} -

{{messageBody}}

-{% endif %} diff --git a/augur/templates/notifications.html b/augur/templates/notifications.html deleted file mode 100644 index b59c673391..0000000000 --- a/augur/templates/notifications.html +++ /dev/null @@ -1,79 +0,0 @@ -{% with messages = get_flashed_messages() %} - -
- {% if messages %} - {% for message in messages %} - - {% endfor %} - {% endif %} -
- - - - -{% endwith %} - diff --git a/augur/templates/repo-commits.html b/augur/templates/repo-commits.html deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/augur/templates/repo-info.html b/augur/templates/repo-info.html deleted file mode 100644 index 311daa45f7..0000000000 --- a/augur/templates/repo-info.html +++ /dev/null @@ -1,128 +0,0 @@ - - -
-
- {% if repo.repo_id %} -

Report for: {{ repo.repo_name|title }}

-

{{ repo.repo_git }}

- {% for report in reports %} -

{{ report|replace("_", " ")|title }}

- {% for image in images[report] %} -
-
-
-
-
- -
-
- {% endfor %} - {% endfor %} - {% else %} -

Repository {{ repo_id }} not found

- {% endif %} -

-
-{% if repo.repo_id %} -{# Wait for cache response: - This method queries the server from the client, asking for confirmation - of which images are available on the server. The server will asynchronously - download the requested images as the page is loading, then once the page - loads, the client will query a locking endpoint on the server and wait - for a response. -#} - -{% endif %} - - - - - - diff --git a/augur/templates/repos-card.html b/augur/templates/repos-card.html deleted file mode 100644 index 9bd6cc4f38..0000000000 --- a/augur/templates/repos-card.html +++ /dev/null @@ -1,27 +0,0 @@ -{% if repos %} -
-
- {% for repo in repos %} -
-
-
-
-
{{ repo.repo_name }}
-

Repository Status: {{ repo.repo_status }}

-

All Time Commits: {{ repo.commits_all_time|int }}

-

All Time Issues: {{ repo.issues_all_time|int }}

-
- -
-
-
- {% endfor %} -
-
-{% elif query_key %} -

Your search did not match any repositories

-{% else %} -

Unable to load repository information

-{% endif %} diff --git a/augur/templates/repos-table.html b/augur/templates/repos-table.html deleted file mode 100644 index 9c08224bde..0000000000 --- a/augur/templates/repos-table.html +++ /dev/null @@ -1,95 +0,0 @@ -{% if repos %} - - - -{# Create the header row for the repo table: - Here we dynamically generate the header row by defining a dictionary list - which contains the titles of each column, accompanied by an optional "key" - item. If a column definition contains a "key" item, that column is assumed - to be sortable, sorting links for that data are generated using the given - key. It is done this way because the client does not receive the full data - each time they load the page, and instead the server sorts the full data. -#} -{# "title" : "Group", "key" : "rg_name"}, #} -{%- set tableHeaders = - [{"title" : "#"}, - {"title" : "Repo Name", "key" : "repo_name"}, - {"title" : "Reports"}, - {"title" : "Commits", "key" : "commits_all_time"}, - {"title" : "Issues", "key" : "issues_all_time"}, - {"title" : "Change Requests"}] -%} -
- - - - - {%- for header in tableHeaders -%} - {% if header.key %} - {%- if sorting == header.key -%} - {%- set sorting_link = url_for(PS, q=query_key, p=activePage, s=header.key, r= not reverse) -%} - {%- else -%} - {%- set sorting_link = url_for(PS, q=query_key, p=activePage, s=header.key) -%} - {%- endif -%} - - {% else -%} - - {% endif %} {%- endfor -%} - - - - - {% for repo in repos %} - - - - {# #} - - - - - - {% endfor %} - -
{{ header.title }} - {%- if sorting == header.key and reverse %} ▲ {% elif sorting == header.key %} ▼ {% endif %}{{ header.title }}
{{loop.index + (activePage) * offset}}{{ repo.repo_name }}{{ repo.rg_name }}TODO{{ repo.commits_all_time|int }}{{ repo.issues_all_time|int }}TODO
-
- -
- -{% elif query_key %} -

Your search did not match any repositories

-{% elif current_user.is_authenticated %} -

No Repos Tracked

-

Add repos to your personal tracker in your profile page

-{% elif activePage != 0 %} -

Invalid Page

-Click here to go back -{% else %} -

Unable to load repository information

-{% endif %} diff --git a/augur/templates/settings.html b/augur/templates/settings.html deleted file mode 100644 index 6ce1e051fa..0000000000 --- a/augur/templates/settings.html +++ /dev/null @@ -1,140 +0,0 @@ -
-
-
-
-
-

{{ current_user.id }}

- Delete Account -
- -
-
-
-

Update Password

- -
-
-
-
-
-

Your Repo Groups

- {%- set groups = current_user.get_groups()["groups"] -%} - {% if groups %} - {% for group in groups %} - {%- set tableHeaders = - [{"title" : "Group ID"}, - {"title" : "Group Name"}] - -%} -
- - - - - {%- for header in tableHeaders -%} - - {%- endfor -%} - - - - - {% for repo in repos %} - - - - - {% endfor %} - -
{{ header.title }}
{{ group.group_id }}{{ group.name }}
-
- {% endfor %} - {% else %} -

No groups created

- {% endif %} -
-
-
-

Add Repos

- -
-
-
- - - \ No newline at end of file diff --git a/augur/templates/status.html b/augur/templates/status.html deleted file mode 100644 index 02b62ed950..0000000000 --- a/augur/templates/status.html +++ /dev/null @@ -1,233 +0,0 @@ - - - -

Collection Status

-
-
-
-

Pull Requests

-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-

Issues

-
-
-
-
-
-
-
-

Time between most recently collected issue and last collection run

-
-
-
-
-
-
-
-

Commits

-
-
-
-
-
-
-
- -
-
- - diff --git a/augur/templates/toasts.html b/augur/templates/toasts.html deleted file mode 100644 index cf707754f2..0000000000 --- a/augur/templates/toasts.html +++ /dev/null @@ -1,60 +0,0 @@ -{% with messages = get_flashed_messages() %} -
- {% if messages %} - {% for message in messages %} - - {% endfor %} - {% endif %} -
- -{% endwith %} - - - - diff --git a/augur/util/repo_load_controller.py b/augur/util/repo_load_controller.py index 118d03f13d..be68b90f80 100644 --- a/augur/util/repo_load_controller.py +++ b/augur/util/repo_load_controller.py @@ -249,7 +249,7 @@ def add_user_group(self, user_id:int, group_name:str) -> dict: """ if not isinstance(user_id, int) or not isinstance(group_name, str): - return {"status": "Invalid input"} + return False, {"status": "Invalid input"} user_group_data = { "name": group_name, @@ -259,13 +259,14 @@ def add_user_group(self, user_id:int, group_name:str) -> dict: try: result = self.session.insert_data(user_group_data, UserGroup, ["name", "user_id"], return_columns=["group_id"]) except s.exc.IntegrityError: - return {"status": "Error: User id does not exist"} + return False, {"status": "Error: User id does not exist"} if result: - return {"status": "Group created"} - else: - return {"status": "Error while creating group"} + return True, {"status": "Group created"} + + + return False, {"status": "Error while creating group"} def remove_user_group(self, user_id: int, group_name: str) -> dict: """ Delete a users group of repos. @@ -282,7 +283,7 @@ def remove_user_group(self, user_id: int, group_name: str) -> dict: # convert group_name to group_id group_id = self.convert_group_name_to_id(user_id, group_name) if group_id is None: - return {"status": "WARNING: Trying to delete group that does not exist"} + return False, {"status": "WARNING: Trying to delete group that does not exist"} group = self.session.query(UserGroup).filter(UserGroup.group_id == group_id).one() @@ -295,7 +296,7 @@ def remove_user_group(self, user_id: int, group_name: str) -> dict: self.session.commit() - return {"status": "Group deleted"} + return True, {"status": "Group deleted"} def convert_group_name_to_id(self, user_id: int, group_name: str) -> int: @@ -348,27 +349,27 @@ def add_frontend_repo(self, url: List[str], user_id: int, group_name=None, group """ if group_name and group_id: - return {"status": "Pass only the group name or group id not both"} + return False, {"status": "Pass only the group name or group id not both"} if group_id is None: group_id = self.convert_group_name_to_id(user_id, group_name) if group_id is None: - return {"status": "Invalid group name"} + return False, {"status": "Invalid group name"} if not valid_repo and not self.is_valid_repo(url): - return {"status": "Invalid repo", "repo_url": url} + return False, {"status": "Invalid repo", "repo_url": url} repo_id = self.add_repo_row(url, DEFAULT_REPO_GROUP_IDS[0], "Frontend") if not repo_id: - return {"status": "Repo insertion failed", "repo_url": url} + return False, {"status": "Repo insertion failed", "repo_url": url} result = self.add_repo_to_user_group(repo_id, group_id) if not result: - return {"status": "repo_user insertion failed", "repo_url": url} + return False, {"status": "repo_user insertion failed", "repo_url": url} - return {"status": "Repo Added", "repo_url": url} + return True, {"status": "Repo Added", "repo_url": url} def remove_frontend_repo(self, repo_id:int, user_id:int, group_name:str) -> dict: """ Remove repo from a users group. @@ -383,17 +384,17 @@ def remove_frontend_repo(self, repo_id:int, user_id:int, group_name:str) -> dict """ if not isinstance(repo_id, int) or not isinstance(user_id, int) or not isinstance(group_name, str): - return {"status": "Invalid types"} + return False, {"status": "Invalid types"} group_id = self.convert_group_name_to_id(user_id, group_name) if group_id is None: - return {"status": "Invalid group name"} + return False, {"status": "Invalid group name"} # delete rows from user repos with group_id self.session.query(UserRepo).filter(UserRepo.group_id == group_id, UserRepo.repo_id == repo_id).delete() self.session.commit() - return {"status": "Repo Removed"} + return True, {"status": "Repo Removed"} def add_frontend_org(self, url: List[str], user_id: int, group_name: int): @@ -405,12 +406,12 @@ def add_frontend_org(self, url: List[str], user_id: int, group_name: int): """ group_id = self.convert_group_name_to_id(user_id, group_name) if group_id is None: - return {"status": "Invalid group name"} + return False, {"status": "Invalid group name"} repos = self.retrieve_org_repos(url) if not repos: - return {"status": "Invalid org", "org_url": url} + return False, {"status": "Invalid org", "org_url": url} # try to get the repo group with this org name # if it does not exist create one @@ -426,9 +427,9 @@ def add_frontend_org(self, url: List[str], user_id: int, group_name: int): failed_count = len(failed_repos) if failed_count > 0: # this should never happen because an org should never return invalid repos - return {"status": f"{failed_count} repos failed", "repo_urls": failed_repos, "org_url": url} + return False, {"status": f"{failed_count} repos failed", "repo_urls": failed_repos, "org_url": url} - return {"status": "Org repos added", "org_url": url} + return True, {"status": "Org repos added"} def add_cli_repo(self, repo_data: Dict[str, Any], valid_repo=False): """Add list of repos to specified repo_groups @@ -520,42 +521,39 @@ def paginate_repos(self, source, page=0, page_size=25, sort="repo_id", direction if not source: print("Func: paginate_repos. Error: Source Required") - return None + return None, {"status": "Source Required"} if source not in ["all", "user", "group"]: print("Func: paginate_repos. Error: Invalid source") - return None + return None, {"Invalid source"} if direction and direction != "ASC" and direction != "DESC": print("Func: paginate_repos. Error: Invalid direction") - # return {"status": "Invalid direction"} - return None + return None, {"status": "Invalid direction"} try: page = int(page) if page else 0 page_size = int(page_size) if page else 25 except TypeError: print("Func: paginate_repos. Error: Page size and page should be integers") - # return {"status": "Page size and page should be integers"} - return None + return None, {"status": "Page size and page should be integers"} if page < 0 or page_size < 0: print("Func: paginate_repos. Error: Page size and page should be positive") - # return {"status": "Page size and page should be postive"} - return None + return None, {"status": "Page size and page should be postive"} order_by = sort if sort else "repo_id" order_direction = direction if direction else "ASC" query = self.generate_repo_query(source, count=False, order_by=order_by, direction=order_direction, page=page, page_size=page_size, **kwargs) - if not query: - return None + if not query[0]: + return None, {"status": query[1]["status"]} - if query == "No data": - return [] + if query[1]["status"] == "No data": + return [], {"status": "No data"} - get_page_of_repos_sql = s.sql.text(query) + get_page_of_repos_sql = s.sql.text(query[0]) results = pd.read_sql(get_page_of_repos_sql, create_database_engine()) results['url'] = results['url'].apply(lambda datum: datum.split('//')[1]) @@ -567,33 +565,33 @@ def paginate_repos(self, source, page=0, page_size=25, sort="repo_id", direction data = results.to_dict(orient="records") - return data + return data, {"status": "success"} def get_repo_count(self, source, **kwargs): if not source: print("Func: get_repo_count. Error: Source Required") - return None + return None, {"status": "Source Required"} if source not in ["all", "user", "group"]: print("Func: get_repo_count. Error: Invalid source") - return None + return None, {"status": "Invalid source"} user = kwargs.get("user") group_name = kwargs.get("group_name") query = self.generate_repo_query(source, count=True, user=user, group_name=group_name) - if not query: - return None + if not query[0]: + return None, query[1] - if query == "No data": - return [] + if query[1]["status"] == "No data": + return 0, {"status": "No data"} - get_page_of_repos_sql = s.sql.text(query) + get_page_of_repos_sql = s.sql.text(query[0]) result = self.session.fetchall_data_from_sql_text(get_page_of_repos_sql) - return result[0]["count"] + return result[0]["count"], {"status": "success"} def generate_repo_query(self, source, count, **kwargs): @@ -625,12 +623,12 @@ def generate_repo_query(self, source, count, **kwargs): user = kwargs.get("user") if not user: print("Func: generate_repo_query. Error: User not passed when trying to get user repos") - return None + return None, {"status": "User not passed when trying to get user repos"} group_ids = tuple(group.group_id for group in user.groups) if not group_ids: - return "No data" + return None, {"status": "No data"} if len(group_ids) == 1: group_ids_str = str(group_ids)[:-2] + ")" @@ -649,16 +647,17 @@ def generate_repo_query(self, source, count, **kwargs): user = kwargs.get("user") if not user: print("Func: generate_repo_query. Error: User not specified") + return None, {"status": "User not specified"} group_name = kwargs.get("group_name") if not group_name: print("Func: generate_repo_query. Error: Group name not specified") - return None + return None, {"status": "Group name not specified"} group_id = controller.convert_group_name_to_id(user.user_id, group_name) if group_id is None: print("Func: generate_repo_query. Error: Group does not exist") - return None + return None, {"status": "Group does not exists"} query += "\t\t JOIN augur_operations.user_repos ON augur_data.repo.repo_id = augur_operations.user_repos.repo_id\n" query += f"\t\t WHERE augur_operations.user_repos.group_id = {group_id}\n" @@ -673,5 +672,5 @@ def generate_repo_query(self, source, count, **kwargs): query += f"\t LIMIT {page_size}\n" query += f"\t OFFSET {page*page_size};\n" - return query + return query, {"status": "success"} From 4dbad5accbf25a5ee52304d263aae769e734bf9d Mon Sep 17 00:00:00 2001 From: Ulincsys <28362836a@gmail.com> Date: Tue, 10 Jan 2023 08:36:11 -0600 Subject: [PATCH 067/150] Track templates directory Signed-off-by: Ulincsys <28362836a@gmail.com> --- augur/templates/admin-dashboard.j2 | 178 +++++++++ augur/templates/authorization.j2 | 52 +++ augur/templates/first-time.j2 | 211 +++++++++++ augur/templates/groups-table.j2 | 27 ++ augur/templates/index.j2 | 67 ++++ augur/templates/loading.j2 | 14 + augur/templates/login.j2 | 155 ++++++++ augur/templates/navbar.j2 | 67 ++++ augur/templates/new_settings.j2 | 347 ++++++++++++++++++ augur/templates/notice.j2 | 6 + augur/templates/notifications.j2 | 79 ++++ augur/templates/repo-commits.j2 | 0 augur/templates/repo-info.j2 | 128 +++++++ augur/templates/repos-card.j2 | 30 ++ augur/templates/repos-table.j2 | 95 +++++ augur/templates/settings.j2 | 421 ++++++++++++++++++++++ augur/templates/settings_old.j2 | 140 +++++++ augur/templates/status.j2 | 233 ++++++++++++ augur/templates/toasts.j2 | 60 +++ augur/templates/user-group-repos-table.j2 | 113 ++++++ 20 files changed, 2423 insertions(+) create mode 100644 augur/templates/admin-dashboard.j2 create mode 100644 augur/templates/authorization.j2 create mode 100644 augur/templates/first-time.j2 create mode 100644 augur/templates/groups-table.j2 create mode 100644 augur/templates/index.j2 create mode 100644 augur/templates/loading.j2 create mode 100644 augur/templates/login.j2 create mode 100644 augur/templates/navbar.j2 create mode 100644 augur/templates/new_settings.j2 create mode 100644 augur/templates/notice.j2 create mode 100644 augur/templates/notifications.j2 create mode 100644 augur/templates/repo-commits.j2 create mode 100644 augur/templates/repo-info.j2 create mode 100644 augur/templates/repos-card.j2 create mode 100644 augur/templates/repos-table.j2 create mode 100644 augur/templates/settings.j2 create mode 100644 augur/templates/settings_old.j2 create mode 100644 augur/templates/status.j2 create mode 100644 augur/templates/toasts.j2 create mode 100644 augur/templates/user-group-repos-table.j2 diff --git a/augur/templates/admin-dashboard.j2 b/augur/templates/admin-dashboard.j2 new file mode 100644 index 0000000000..a24829c99f --- /dev/null +++ b/augur/templates/admin-dashboard.j2 @@ -0,0 +1,178 @@ + + + + + + + + + + + + + + + + + + + + + Dasboard - Augur View + + + + + +
+
+
+
+ Dashboard +
+
+ +
+ +
+ {# Start dashboard content #} +
+

Stats

+ {# Start content card #} +
+
+ {# Start form body #} +
+ {% for section in sections %} +
+
+
{{ section.title }}
+
+ {% for setting in section.settings %} +
+
+ + +
{{ setting.description or "No description available" }}
+
+
+ {% endfor %} +
+ {% endfor %} + {#
+
+ +
+
#} +
+
+
+

User Accounts

+ {# Start content card #} +
+
+
+ {% for section in sections %} +
+
+
{{ section.title }}
+
+ {% for setting in section.settings %} +
+
+ + +
{{ setting.description or "No description available" }}
+
+
+ {% endfor %} +
+ {% endfor %} + {#
+
+ +
+
#} +
+
+
+

Configuration

+ {# Start content card #} +
+
+
+ {% for section in config.items() %} +
+
+
{{ section[0] }}
+
+ {% for setting in section[1].items() %} +
+
+ + +
No description available
+
+
+ {% endfor %} +
+ {% endfor %} +
+
+ +
+
+
+
+
+
+
+
+ + + + diff --git a/augur/templates/authorization.j2 b/augur/templates/authorization.j2 new file mode 100644 index 0000000000..d792fa3d69 --- /dev/null +++ b/augur/templates/authorization.j2 @@ -0,0 +1,52 @@ +

Authorize App

+ +

{{ app.name }} is requesting access to your account.

+

Authorizing this application will grant it access to the following:

+
    +
  • Username
  • +
  • Your repo groups
  • +
  • Information collected by Augur, both public and private:
  • +
  • +
      +
    • Issues
    • +
    • Pull requests
    • +
    • Comments
    • +
    • Commit logs
    • +
    +
  • +
+ +

By continuing, you authorize this access, and will be redirected to the following link:

+

{{ app.redirect_url }}

+Make sure you trust the application and this link before proceeding. + +
+ + +
+
+ +
+
+ + \ No newline at end of file diff --git a/augur/templates/first-time.j2 b/augur/templates/first-time.j2 new file mode 100644 index 0000000000..c8eb284da8 --- /dev/null +++ b/augur/templates/first-time.j2 @@ -0,0 +1,211 @@ +{# https://www.bootdey.com/snippets/view/dark-profile-settings #} + + + + + + + + + + + + + + +
+
+ {# Start sidebar #} +
+
+
+ +
+ +
+
+ {# Start form body #} +
+
+
+
+ {% for section in sections %} +
+
+
{{ section.title }}
+
+ {% for setting in section.settings %} +
+
+ + +
{{ setting.description }}
+
+
+ {% endfor %} +
+ {% endfor %} +
+
+
Gunicorn Settings
+
+
+
+
{{ gunicorn_placeholder }}
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+ + + + + + + diff --git a/augur/templates/groups-table.j2 b/augur/templates/groups-table.j2 new file mode 100644 index 0000000000..ccc0e2a3f3 --- /dev/null +++ b/augur/templates/groups-table.j2 @@ -0,0 +1,27 @@ +{#% if groups %} +
+ + + + + + + + + + {% for group in groups %} + + + + + + + {% endfor %} + +
#Group NameData Collectio
{{loop.index}}{{ group.name }}{{ group.data_collection_date }}TODO
+
+{% elif query_key %} +

Your search did not match any results

+{% else %} +

Unable to load group information

+{% endif %#} diff --git a/augur/templates/index.j2 b/augur/templates/index.j2 new file mode 100644 index 0000000000..89cd6734c3 --- /dev/null +++ b/augur/templates/index.j2 @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + {% if title %} + {{title}} - Augur View + {% else %} + Augur View + {% endif %} + + {% if redirect %} + + {% endif %} + + + + + + + {% include 'notifications.j2' %} + + {% include 'navbar.j2' %} + +
+ {% if invalid %} +

Invalid API URL

+

The API URL [{{ api_url or 'unspecified'}}] is invalid

+ {% elif body %} + {% include '%s.j2' % body ignore missing %} + {% else %} +

404 - Page Not Found

+

The page you were looking for isn't here, try clicking one of the navigation links above

+ {% endif %} +
+ + + + diff --git a/augur/templates/loading.j2 b/augur/templates/loading.j2 new file mode 100644 index 0000000000..052af79eab --- /dev/null +++ b/augur/templates/loading.j2 @@ -0,0 +1,14 @@ +{% if not d %} +

Uh oh, Something went wrong!

+

You were sent to this page because we were loading something for you, but we didn't catch your destination.

+

Go back to the previous page and try again. If that doesn't help, submit an issue to https://github.com/chaoss/augur .

+{% else %} + +

Give us a moment!

+

We are retreiving some data for you, and it may take up to a few seconds to load.

+

If you aren't redirected in a few seconds, go back to the previous page and try again.

+ +

Redirecting to: {{url_for('root', path=d)}}

+{% endif %} diff --git a/augur/templates/login.j2 b/augur/templates/login.j2 new file mode 100644 index 0000000000..c71d02d50f --- /dev/null +++ b/augur/templates/login.j2 @@ -0,0 +1,155 @@ +
+
+ +
+
+ + + + \ No newline at end of file diff --git a/augur/templates/navbar.j2 b/augur/templates/navbar.j2 new file mode 100644 index 0000000000..fe498548a9 --- /dev/null +++ b/augur/templates/navbar.j2 @@ -0,0 +1,67 @@ + diff --git a/augur/templates/new_settings.j2 b/augur/templates/new_settings.j2 new file mode 100644 index 0000000000..74a14ed575 --- /dev/null +++ b/augur/templates/new_settings.j2 @@ -0,0 +1,347 @@ + + + + + + + + + + + + + + + + + + + + + + Settings - Augur View + + + + + + {% include 'notifications.j2' %} +
+
+
+
+ Settings +
+
+ +
+ +
+ {# Start dashboard content #} +
+
+

Profile

+ {# Start content card #} +
+
+ {# Start form body #} +
+
+
+
+

{{ current_user.id }}

+ Delete Account +
+ +
+
+
+

Update Password

+ +
+
+
+
+
+ + +
+
+
+ + + + + + + \ No newline at end of file diff --git a/augur/templates/notice.j2 b/augur/templates/notice.j2 new file mode 100644 index 0000000000..46ed7ead66 --- /dev/null +++ b/augur/templates/notice.j2 @@ -0,0 +1,6 @@ +{% if messageTitle %} +

{{messageTitle}}

+{% endif %} +{% if messageBody %} +

{{messageBody}}

+{% endif %} diff --git a/augur/templates/notifications.j2 b/augur/templates/notifications.j2 new file mode 100644 index 0000000000..b59c673391 --- /dev/null +++ b/augur/templates/notifications.j2 @@ -0,0 +1,79 @@ +{% with messages = get_flashed_messages() %} + +
+ {% if messages %} + {% for message in messages %} + + {% endfor %} + {% endif %} +
+ + + + +{% endwith %} + diff --git a/augur/templates/repo-commits.j2 b/augur/templates/repo-commits.j2 new file mode 100644 index 0000000000..e69de29bb2 diff --git a/augur/templates/repo-info.j2 b/augur/templates/repo-info.j2 new file mode 100644 index 0000000000..311daa45f7 --- /dev/null +++ b/augur/templates/repo-info.j2 @@ -0,0 +1,128 @@ + + +
+
+ {% if repo.repo_id %} +

Report for: {{ repo.repo_name|title }}

+

{{ repo.repo_git }}

+ {% for report in reports %} +

{{ report|replace("_", " ")|title }}

+ {% for image in images[report] %} +
+
+
+
+
+ +
+
+ {% endfor %} + {% endfor %} + {% else %} +

Repository {{ repo_id }} not found

+ {% endif %} +

+
+{% if repo.repo_id %} +{# Wait for cache response: + This method queries the server from the client, asking for confirmation + of which images are available on the server. The server will asynchronously + download the requested images as the page is loading, then once the page + loads, the client will query a locking endpoint on the server and wait + for a response. +#} + +{% endif %} + + + + + + diff --git a/augur/templates/repos-card.j2 b/augur/templates/repos-card.j2 new file mode 100644 index 0000000000..04e4ed3871 --- /dev/null +++ b/augur/templates/repos-card.j2 @@ -0,0 +1,30 @@ +{% if repos %} +
+
+ {% for repo in repos %} +
+
+
+
+
{{ repo.repo_name }}
+

Repository Status: {{ repo.repo_status }}

+

All Time Commits: {{ repo.commits_all_time|int }}

+

All Time Issues: {{ repo.issues_all_time|int }}

+
+ +
+
+
+ {% endfor %} +
+
+{% elif query_key %} +

Your search did not match any repositories

+{% elif current_user.is_authenticated %} +

No Repos Tracked

+

Add repos to your personal tracker in your profile page

+{% else %} +

Unable to load repository information

+{% endif %} diff --git a/augur/templates/repos-table.j2 b/augur/templates/repos-table.j2 new file mode 100644 index 0000000000..9c08224bde --- /dev/null +++ b/augur/templates/repos-table.j2 @@ -0,0 +1,95 @@ +{% if repos %} + + + +{# Create the header row for the repo table: + Here we dynamically generate the header row by defining a dictionary list + which contains the titles of each column, accompanied by an optional "key" + item. If a column definition contains a "key" item, that column is assumed + to be sortable, sorting links for that data are generated using the given + key. It is done this way because the client does not receive the full data + each time they load the page, and instead the server sorts the full data. +#} +{# "title" : "Group", "key" : "rg_name"}, #} +{%- set tableHeaders = + [{"title" : "#"}, + {"title" : "Repo Name", "key" : "repo_name"}, + {"title" : "Reports"}, + {"title" : "Commits", "key" : "commits_all_time"}, + {"title" : "Issues", "key" : "issues_all_time"}, + {"title" : "Change Requests"}] -%} +
+ + + + + {%- for header in tableHeaders -%} + {% if header.key %} + {%- if sorting == header.key -%} + {%- set sorting_link = url_for(PS, q=query_key, p=activePage, s=header.key, r= not reverse) -%} + {%- else -%} + {%- set sorting_link = url_for(PS, q=query_key, p=activePage, s=header.key) -%} + {%- endif -%} + + {% else -%} + + {% endif %} {%- endfor -%} + + + + + {% for repo in repos %} + + + + {# #} + + + + + + {% endfor %} + +
{{ header.title }} + {%- if sorting == header.key and reverse %} ▲ {% elif sorting == header.key %} ▼ {% endif %}{{ header.title }}
{{loop.index + (activePage) * offset}}{{ repo.repo_name }}{{ repo.rg_name }}TODO{{ repo.commits_all_time|int }}{{ repo.issues_all_time|int }}TODO
+
+ +
+ +{% elif query_key %} +

Your search did not match any repositories

+{% elif current_user.is_authenticated %} +

No Repos Tracked

+

Add repos to your personal tracker in your profile page

+{% elif activePage != 0 %} +

Invalid Page

+Click here to go back +{% else %} +

Unable to load repository information

+{% endif %} diff --git a/augur/templates/settings.j2 b/augur/templates/settings.j2 new file mode 100644 index 0000000000..aa09f265fe --- /dev/null +++ b/augur/templates/settings.j2 @@ -0,0 +1,421 @@ + + + + + + + + + + + + + + + + + + + + + + Settings - Augur View + + + + + + {% include 'notifications.j2' %} +
+
+
+
+ Settings +
+
+ +
+ +
+ {# Start dashboard content #} +
+
+

Profile

+ {# Start content card #} +
+
+ {# Start form body #} +
+
+
+
+

{{ current_user.id }}

+ Delete Account +
+ +
+
+
+

Update Password

+ +
+
+
+
+
+ + +
+
+
+ + + + + + + + \ No newline at end of file diff --git a/augur/templates/settings_old.j2 b/augur/templates/settings_old.j2 new file mode 100644 index 0000000000..f62a066ab7 --- /dev/null +++ b/augur/templates/settings_old.j2 @@ -0,0 +1,140 @@ +
+
+
+
+
+

{{ current_user.id }}

+ Delete Account +
+ +
+
+
+

Update Password

+ +
+
+
+
+
+

Your Repo Groups

+ {%- set groups = current_user.get_groups()["groups"] -%} + {% if groups %} + {% for group in groups %} + {%- set tableHeaders = + [{"title" : "Group ID"}, + {"title" : "Group Name"}] + -%} +
+ + + + + {%- for header in tableHeaders -%} + + {%- endfor -%} + + + + + {% for repo in repos %} + + + + + {% endfor %} + +
{{ header.title }}
{{ group.group_id }}{{ group.name }}
+
+ {% endfor %} + {% else %} +

No groups created

+ {% endif %} +
+
+
+

Add Repos

+ +
+
+
+ + + \ No newline at end of file diff --git a/augur/templates/status.j2 b/augur/templates/status.j2 new file mode 100644 index 0000000000..02b62ed950 --- /dev/null +++ b/augur/templates/status.j2 @@ -0,0 +1,233 @@ + + + +

Collection Status

+
+
+
+

Pull Requests

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

Issues

+
+
+
+
+
+
+
+

Time between most recently collected issue and last collection run

+
+
+
+
+
+
+
+

Commits

+
+
+
+
+
+
+
+ +
+
+ + diff --git a/augur/templates/toasts.j2 b/augur/templates/toasts.j2 new file mode 100644 index 0000000000..cf707754f2 --- /dev/null +++ b/augur/templates/toasts.j2 @@ -0,0 +1,60 @@ +{% with messages = get_flashed_messages() %} +
+ {% if messages %} + {% for message in messages %} + + {% endfor %} + {% endif %} +
+ +{% endwith %} + + + + diff --git a/augur/templates/user-group-repos-table.j2 b/augur/templates/user-group-repos-table.j2 new file mode 100644 index 0000000000..0b3adefc25 --- /dev/null +++ b/augur/templates/user-group-repos-table.j2 @@ -0,0 +1,113 @@ +{% if repos %} + +

{{ group }}

+ + +{# Create the header row for the repo table: + Here we dynamically generate the header row by defining a dictionary list + which contains the titles of each column, accompanied by an optional "key" + item. If a column definition contains a "key" item, that column is assumed + to be sortable, sorting links for that data are generated using the given + key. It is done this way because the client does not receive the full data + each time they load the page, and instead the server sorts the full data. +#} +{# "title" : "Group", "key" : "rg_name"}, #} +{%- set tableHeaders = + [{"title" : "#"}, + {"title" : "Repo Name", "key" : "repo_name"}, + {"title" : "Reports"}, + {"title" : "Commits", "key" : "commits_all_time"}, + {"title" : "Issues", "key" : "issues_all_time"}, + {"title" : "Change Requests"}, + {"title" : "Remove"}] -%} +
+ + + + + {%- for header in tableHeaders -%} + {% if header.key %} + {%- if sorting == header.key -%} + {%- set sorting_link = url_for(PS, q=query_key, p=activePage, s=header.key, r= not reverse) -%} + {%- else -%} + {%- set sorting_link = url_for(PS, q=query_key, p=activePage, s=header.key) -%} + {%- endif -%} + + {% else -%} + + {% endif %} {%- endfor -%} + + + + + {% for repo in repos %} + + + + {# #} + + + + + + + {% endfor %} + +
{{ header.title }} + {%- if sorting == header.key and reverse %} ▲ {% elif sorting == header.key %} ▼ {% endif %}{{ header.title }}
{{loop.index + (activePage) * offset}}{{ repo.repo_name }}{{ repo.rg_name }}TODO{{ repo.commits_all_time|int }}{{ repo.issues_all_time|int }}TODO
+
+ +
+ +{% elif query_key %} +

Your search did not match any repositories

+{% elif current_user.is_authenticated %} +

No Repos Tracked

+

Add repos to your personal tracker in your profile page

+{% elif activePage != 0 %} +

Invalid Page

+Click here to go back +{% else %} +

No repos in group

+

Add repos to this group in your profile page

+{% endif %} + + From fdfa7b9b45655cb2e8556e7f2d7a5157d3cbc6ba Mon Sep 17 00:00:00 2001 From: Andrew Brain Date: Tue, 10 Jan 2023 14:54:35 -0600 Subject: [PATCH 068/150] Make default group allowed, and return user group exists if it does Signed-off-by: Andrew Brain --- augur/application/db/models/augur_operations.py | 7 ++----- augur/util/repo_load_controller.py | 4 ++++ 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/augur/application/db/models/augur_operations.py b/augur/application/db/models/augur_operations.py index 5c5d6f7f54..854a6189d4 100644 --- a/augur/application/db/models/augur_operations.py +++ b/augur/application/db/models/augur_operations.py @@ -288,8 +288,8 @@ def create_user(username: str, password: str, email: str, first_name:str, last_n local_session.add(user) local_session.commit() - result = user.add_group(f"{username}_default")[0] - if not result: + result = user.add_group("default") + if not result[0] and result[1]["status"] != "Group already exists": return False, {"status": "Failed to add default group for the user"} return True, {"status": "Account successfully created"} @@ -371,9 +371,6 @@ def update_username(self, new_username): def add_group(self, group_name): from augur.util.repo_load_controller import RepoLoadController - - if group_name == "default": - return False, {"status": "Reserved Group Name"} local_session = get_session() diff --git a/augur/util/repo_load_controller.py b/augur/util/repo_load_controller.py index be68b90f80..55e8c32feb 100644 --- a/augur/util/repo_load_controller.py +++ b/augur/util/repo_load_controller.py @@ -256,6 +256,10 @@ def add_user_group(self, user_id:int, group_name:str) -> dict: "user_id": user_id } + user_group = self.session.query(UserGroup).filter(UserGroup.user_id == user_id, UserGroup, UserGroup.name == group_name).first() + if user_group: + return False, {"status": "Group already exists"} + try: result = self.session.insert_data(user_group_data, UserGroup, ["name", "user_id"], return_columns=["group_id"]) except s.exc.IntegrityError: From 8efebe27c814b06114e7d3120d21e167ac99250f Mon Sep 17 00:00:00 2001 From: Isaac Milarsky Date: Tue, 10 Jan 2023 15:24:19 -0600 Subject: [PATCH 069/150] Change celery task scheduling to not scale proportionally to the amount of repos Signed-off-by: Isaac Milarsky --- augur/tasks/github/detect_move/tasks.py | 2 +- augur/tasks/github/events/tasks.py | 44 ++++++++++--------- augur/tasks/github/issues/tasks.py | 36 ++++++++------- augur/tasks/github/messages/tasks.py | 31 ++++++------- .../pull_requests/commits_model/tasks.py | 19 ++++---- .../github/pull_requests/files_model/tasks.py | 19 ++++---- augur/tasks/github/pull_requests/tasks.py | 29 ++++++------ augur/tasks/github/repo_info/tasks.py | 20 +++++---- augur/tasks/start_tasks.py | 28 ++++++++---- 9 files changed, 124 insertions(+), 104 deletions(-) diff --git a/augur/tasks/github/detect_move/tasks.py b/augur/tasks/github/detect_move/tasks.py index 2acc440747..f47d800b82 100644 --- a/augur/tasks/github/detect_move/tasks.py +++ b/augur/tasks/github/detect_move/tasks.py @@ -6,7 +6,7 @@ @celery.task -def detect_github_repo_move(repo_git_identifiers : str) -> None: +def detect_github_repo_move(repo_git_identifiers : [str]) -> None: logger = logging.getLogger(detect_github_repo_move.__name__) logger.info(f"Starting repo_move operation with {repo_git_identifiers}") diff --git a/augur/tasks/github/events/tasks.py b/augur/tasks/github/events/tasks.py index 78f356d915..cb8c175e91 100644 --- a/augur/tasks/github/events/tasks.py +++ b/augur/tasks/github/events/tasks.py @@ -15,31 +15,33 @@ @celery.task -def collect_events(repo_git: str): +def collect_events(repo_git_identifiers: [str]): logger = logging.getLogger(collect_events.__name__) - # define GithubTaskSession to handle insertions, and store oauth keys - with GithubTaskSession(logger) as session: - - query = session.query(Repo).filter(Repo.repo_git == repo_git) - repo_obj = execute_session_query(query, 'one') - repo_id = repo_obj.repo_id - - owner, repo = get_owner_repo(repo_git) - - logger.info(f"Collecting Github events for {owner}/{repo}") - - url = f"https://api.github.com/repos/{owner}/{repo}/issues/events" - - event_data = retrieve_all_event_data(repo_git, logger) - - if event_data: - - process_events(event_data, f"{owner}/{repo}: Event task", repo_id, logger) - else: - logger.info(f"{owner}/{repo} has no events") + for repo_git in repo_git_identifiers: + # define GithubTaskSession to handle insertions, and store oauth keys + with GithubTaskSession(logger) as session: + + query = session.query(Repo).filter(Repo.repo_git == repo_git) + repo_obj = execute_session_query(query, 'one') + repo_id = repo_obj.repo_id + + owner, repo = get_owner_repo(repo_git) + + logger.info(f"Collecting Github events for {owner}/{repo}") + + url = f"https://api.github.com/repos/{owner}/{repo}/issues/events" + + event_data = retrieve_all_event_data(repo_git, logger) + + if event_data: + + process_events(event_data, f"{owner}/{repo}: Event task", repo_id, logger) + + else: + logger.info(f"{owner}/{repo} has no events") def retrieve_all_event_data(repo_git: str, logger): diff --git a/augur/tasks/github/issues/tasks.py b/augur/tasks/github/issues/tasks.py index d75c86c279..83dbbb02bb 100644 --- a/augur/tasks/github/issues/tasks.py +++ b/augur/tasks/github/issues/tasks.py @@ -18,27 +18,29 @@ development = get_development_flag() @celery.task -def collect_issues(repo_git: str) -> None: +def collect_issues(repo_git_identifiers: [str]) -> None: logger = logging.getLogger(collect_issues.__name__) - owner, repo = get_owner_repo(repo_git) - # define GithubTaskSession to handle insertions, and store oauth keys - with GithubTaskSession(logger) as session: - - query = session.query(Repo).filter(Repo.repo_git == repo_git) - repo_obj = execute_session_query(query, 'one') - repo_id = repo_obj.repo_id + for repo_git in repo_git_identifiers: + owner, repo = get_owner_repo(repo_git) + + # define GithubTaskSession to handle insertions, and store oauth keys + with GithubTaskSession(logger) as session: + + query = session.query(Repo).filter(Repo.repo_git == repo_git) + repo_obj = execute_session_query(query, 'one') + repo_id = repo_obj.repo_id + + + issue_data = retrieve_all_issue_data(repo_git, logger) + + if issue_data: - - issue_data = retrieve_all_issue_data(repo_git, logger) - - if issue_data: - - process_issues(issue_data, f"{owner}/{repo}: Issue task", repo_id, logger) - - else: - logger.info(f"{owner}/{repo} has no issues") + process_issues(issue_data, f"{owner}/{repo}: Issue task", repo_id, logger) + + else: + logger.info(f"{owner}/{repo} has no issues") def retrieve_all_issue_data(repo_git, logger) -> None: diff --git a/augur/tasks/github/messages/tasks.py b/augur/tasks/github/messages/tasks.py index 26a4769494..89ea3e1c6e 100644 --- a/augur/tasks/github/messages/tasks.py +++ b/augur/tasks/github/messages/tasks.py @@ -17,24 +17,25 @@ @celery.task -def collect_github_messages(repo_git: str) -> None: +def collect_github_messages(repo_git_identifiers: [str]) -> None: logger = logging.getLogger(collect_github_messages.__name__) - with GithubTaskSession(logger, engine) as session: - - repo_id = session.query(Repo).filter( - Repo.repo_git == repo_git).one().repo_id - - owner, repo = get_owner_repo(repo_git) - message_data = retrieve_all_pr_and_issue_messages(repo_git, logger) - - if message_data: - - process_messages(message_data, f"{owner}/{repo}: Message task", repo_id, logger) - - else: - logger.info(f"{owner}/{repo} has no messages") + for repo_git in repo_git_identifiers: + with GithubTaskSession(logger, engine) as session: + + repo_id = session.query(Repo).filter( + Repo.repo_git == repo_git).one().repo_id + + owner, repo = get_owner_repo(repo_git) + message_data = retrieve_all_pr_and_issue_messages(repo_git, logger) + + if message_data: + + process_messages(message_data, f"{owner}/{repo}: Message task", repo_id, logger) + + else: + logger.info(f"{owner}/{repo} has no messages") def retrieve_all_pr_and_issue_messages(repo_git: str, logger) -> None: diff --git a/augur/tasks/github/pull_requests/commits_model/tasks.py b/augur/tasks/github/pull_requests/commits_model/tasks.py index 9a9c834e9c..e50ea9b4ea 100644 --- a/augur/tasks/github/pull_requests/commits_model/tasks.py +++ b/augur/tasks/github/pull_requests/commits_model/tasks.py @@ -7,14 +7,15 @@ @celery.task -def process_pull_request_commits(repo_git: str) -> None: +def process_pull_request_commits(repo_git_identifiers: [str]) -> None: logger = logging.getLogger(process_pull_request_commits.__name__) - with GithubTaskSession(logger) as session: - query = session.query(Repo).filter(Repo.repo_git == repo_git) - repo = execute_session_query(query, 'one') - try: - pull_request_commits_model(repo.repo_id, logger) - except Exception as e: - logger.error(f"Could not complete pull_request_commits_model!\n Reason: {e} \n Traceback: {''.join(traceback.format_exception(None, e, e.__traceback__))}") - raise e + for repo_git in repo_git_identifiers: + with GithubTaskSession(logger) as session: + query = session.query(Repo).filter(Repo.repo_git == repo_git) + repo = execute_session_query(query, 'one') + try: + pull_request_commits_model(repo.repo_id, logger) + except Exception as e: + logger.error(f"Could not complete pull_request_commits_model!\n Reason: {e} \n Traceback: {''.join(traceback.format_exception(None, e, e.__traceback__))}") + raise e diff --git a/augur/tasks/github/pull_requests/files_model/tasks.py b/augur/tasks/github/pull_requests/files_model/tasks.py index 6ed40811a9..fbe29795ac 100644 --- a/augur/tasks/github/pull_requests/files_model/tasks.py +++ b/augur/tasks/github/pull_requests/files_model/tasks.py @@ -6,14 +6,15 @@ from augur.application.db.util import execute_session_query @celery.task -def process_pull_request_files(repo_git: str) -> None: +def process_pull_request_files(repo_git_identifiers: str) -> None: logger = logging.getLogger(process_pull_request_files.__name__) - with GithubTaskSession(logger) as session: - query = session.query(Repo).filter(Repo.repo_git == repo_git) - repo = execute_session_query(query, 'one') - try: - pull_request_files_model(repo.repo_id, logger) - except Exception as e: - logger.error(f"Could not complete pull_request_files_model!\n Reason: {e} \n Traceback: {''.join(traceback.format_exception(None, e, e.__traceback__))}") - #raise e \ No newline at end of file + for repo_git in repo_git_identifiers: + with GithubTaskSession(logger) as session: + query = session.query(Repo).filter(Repo.repo_git == repo_git) + repo = execute_session_query(query, 'one') + try: + pull_request_files_model(repo.repo_id, logger) + except Exception as e: + logger.error(f"Could not complete pull_request_files_model!\n Reason: {e} \n Traceback: {''.join(traceback.format_exception(None, e, e.__traceback__))}") + #raise e \ No newline at end of file diff --git a/augur/tasks/github/pull_requests/tasks.py b/augur/tasks/github/pull_requests/tasks.py index 848a78f5b7..2b3383bcc7 100644 --- a/augur/tasks/github/pull_requests/tasks.py +++ b/augur/tasks/github/pull_requests/tasks.py @@ -17,22 +17,23 @@ @celery.task -def collect_pull_requests(repo_git: str) -> None: +def collect_pull_requests(repo_git_identifiers: [str]) -> None: logger = logging.getLogger(collect_pull_requests.__name__) - - with GithubTaskSession(logger, engine) as session: - - repo_id = session.query(Repo).filter( - Repo.repo_git == repo_git).one().repo_id - - owner, repo = get_owner_repo(repo_git) - pr_data = retrieve_all_pr_data(repo_git, logger) - - if pr_data: - process_pull_requests(pr_data, f"{owner}/{repo}: Pr task", repo_id, logger) - else: - logger.info(f"{owner}/{repo} has no pull requests") + + for repo_git in repo_git_identifiers: + with GithubTaskSession(logger, engine) as session: + + repo_id = session.query(Repo).filter( + Repo.repo_git == repo_git).one().repo_id + + owner, repo = get_owner_repo(repo_git) + pr_data = retrieve_all_pr_data(repo_git, logger) + + if pr_data: + process_pull_requests(pr_data, f"{owner}/{repo}: Pr task", repo_id, logger) + else: + logger.info(f"{owner}/{repo} has no pull requests") # TODO: Rename pull_request_reviewers table to pull_request_requested_reviewers diff --git a/augur/tasks/github/repo_info/tasks.py b/augur/tasks/github/repo_info/tasks.py index 010b2114a0..c739cb49d0 100644 --- a/augur/tasks/github/repo_info/tasks.py +++ b/augur/tasks/github/repo_info/tasks.py @@ -5,16 +5,18 @@ import traceback @celery.task -def collect_repo_info(repo_git: str): +def collect_repo_info(repo_git_identifiers: [str]): logger = logging.getLogger(collect_repo_info.__name__) with GithubTaskSession(logger, engine) as session: - query = session.query(Repo).filter(Repo.repo_git == repo_git) - repo = execute_session_query(query, 'one') - try: - repo_info_model(session, repo) - except Exception as e: - session.logger.error(f"Could not add repo info for repo {repo.repo_id}\n Error: {e}") - session.logger.error( - ''.join(traceback.format_exception(None, e, e.__traceback__))) \ No newline at end of file + + for repo_git in repo_git_identifiers: + query = session.query(Repo).filter(Repo.repo_git == repo_git) + repo = execute_session_query(query, 'one') + try: + repo_info_model(session, repo) + except Exception as e: + session.logger.error(f"Could not add repo info for repo {repo.repo_id}\n Error: {e}") + session.logger.error( + ''.join(traceback.format_exception(None, e, e.__traceback__))) \ No newline at end of file diff --git a/augur/tasks/start_tasks.py b/augur/tasks/start_tasks.py index f8c02cc95d..561b3005ce 100644 --- a/augur/tasks/start_tasks.py +++ b/augur/tasks/start_tasks.py @@ -62,20 +62,30 @@ def repo_collect_phase(): with DatabaseSession(logger) as session: query = session.query(Repo) repos = execute_session_query(query, 'all') - #Just use list comprehension for simple group - repo_info_tasks = [collect_repo_info.si(repo.repo_git) for repo in repos] - for repo in repos: - first_tasks_repo = group(collect_issues.si(repo.repo_git),collect_pull_requests.si(repo.repo_git)) - second_tasks_repo = group(collect_events.si(repo.repo_git), - collect_github_messages.si(repo.repo_git),process_pull_request_files.si(repo.repo_git), process_pull_request_commits.si(repo.repo_git)) - repo_chain = chain(first_tasks_repo,second_tasks_repo) - issue_dependent_tasks.append(repo_chain) + all_repo_git_identifiers = [repo.repo_git for repo in repos] + + #Pool the tasks for collecting repo info. + repo_info_tasks = create_grouped_task_load(dataList=all_repo_git_identifiers, task=collect_repo_info).tasks + + #pool the repo collection jobs that should be ran first and have deps. + primary_repo_jobs = group( + *create_grouped_task_load(dataList=all_repo_git_identifiers, task=collect_issues).tasks, + *create_grouped_task_load(dataList=all_repo_git_identifiers, task=collect_pull_requests).tasks + ) + + secondary_repo_jobs = group( + *create_grouped_task_load(dataList=all_repo_git_identifiers, task=collect_events).tasks, + *create_grouped_task_load(dataList=all_repo_git_identifiers,task=collect_github_messages).tasks, + *create_grouped_task_load(dataList=all_repo_git_identifiers, task=process_pull_request_files).tasks, + *create_grouped_task_load(dataList=all_repo_git_identifiers, task=process_pull_request_commits).tasks + ) + repo_task_group = group( *repo_info_tasks, - chain(group(*issue_dependent_tasks),process_contributors.si()), + chain(primary_repo_jobs,secondary_repo_jobs,process_contributors.si()), generate_facade_chain(logger), collect_releases.si() ) From e79980a3a36699a1f0c6617b121395c5f30afb96 Mon Sep 17 00:00:00 2001 From: Andrew Brain Date: Tue, 10 Jan 2023 15:43:14 -0600 Subject: [PATCH 070/150] Fix errors in the api Signed-off-by: Andrew Brain --- augur/api/routes/user.py | 12 +++++---- augur/util/repo_load_controller.py | 43 +++++++++++++++++------------- 2 files changed, 31 insertions(+), 24 deletions(-) diff --git a/augur/api/routes/user.py b/augur/api/routes/user.py index ebae5c94f7..a011c64163 100644 --- a/augur/api/routes/user.py +++ b/augur/api/routes/user.py @@ -124,13 +124,11 @@ def user_authorize(): def generate_session(application): code = request.args.get("code") if not code: - print("Passed empty code") return jsonify({"status": "Invalid authorization code"}) username = redis.get(code) redis.delete(code) if not username: - print("Could not find in redis") return jsonify({"status": "Invalid authorization code"}) user = User.get_user(username) @@ -253,7 +251,7 @@ def add_user_group(): return jsonify(result[1]) - @server.app.route(f"/{AUGUR_API_VERSION}/user/remove_group", methods=['GET', 'POST']) + @server.app.route(f"/{AUGUR_API_VERSION}/user/group/remove", methods=['GET', 'POST']) @login_required def remove_user_group(): if not development and not request.is_secure: @@ -288,7 +286,11 @@ def remove_user_repo(): group_name = request.args.get("group_name") - repo_id = request.args.get("repo_id") + + try: + repo_id = int(request.args.get("repo_id")) + except TypeError: + return {"status": "Repo id must be and integer"} result = current_user.remove_repo(group_name, repo_id) @@ -360,7 +362,7 @@ def group_repo_count(): group_name = request.args.get("group_name") - result = current_user.group_repo_count(group_name) + result = current_user.get_group_repo_count(group_name) result_dict = result[1] if result[0] is not None: diff --git a/augur/util/repo_load_controller.py b/augur/util/repo_load_controller.py index 55e8c32feb..99cce626ea 100644 --- a/augur/util/repo_load_controller.py +++ b/augur/util/repo_load_controller.py @@ -86,9 +86,12 @@ def is_valid_repo(self, url: str) -> bool: True if repo url is valid and False if not """ + if not self.session.oauths.list_of_keys: + return False, {"status": "No valid github api keys to retrieve data with"} + owner, repo = parse_repo_url(url) if not owner or not repo: - return False + return False, {"status":"Invalid repo url"} url = REPO_ENDPOINT.format(owner, repo) @@ -103,9 +106,9 @@ def is_valid_repo(self, url: str) -> bool: # if there was an error return False if "message" in result.json().keys(): - return False + return False, {"status": f"Github Error: {result.json()['message']}"} - return True + return True, {"status": "Valid repo"} def retrieve_org_repos(self, url: str) -> List[str]: @@ -123,13 +126,16 @@ def retrieve_org_repos(self, url: str) -> List[str]: owner = parse_org_url(url) if not owner: - return [] + return None, {"status": "Invalid owner url"} url = ORG_REPOS_ENDPOINT.format(owner) repos = [] with GithubTaskSession(logger) as session: + if not session.oauths.list_of_keys: + return None, {"status": "No valid github api keys to retrieve data with"} + for page_data, page in GithubPaginator(url, session.oauths, logger).iter_pages(): if page_data is None: @@ -139,7 +145,7 @@ def retrieve_org_repos(self, url: str) -> List[str]: repo_urls = [repo["html_url"] for repo in repos] - return repo_urls + return repo_urls, {"status": "Invalid owner url"} def is_valid_repo_group_id(self, repo_group_id: int) -> bool: @@ -284,12 +290,9 @@ def remove_user_group(self, user_id: int, group_name: str) -> dict: """ - # convert group_name to group_id - group_id = self.convert_group_name_to_id(user_id, group_name) - if group_id is None: - return False, {"status": "WARNING: Trying to delete group that does not exist"} - - group = self.session.query(UserGroup).filter(UserGroup.group_id == group_id).one() + group = self.session.query(UserGroup).filter(UserGroup.name == group_name, UserGroup.user_id == user_id).first() + if not group: + return False, {"status": "WARNING: Trying to delete group that does not exist"} # delete rows from user repos with group_id for repo in group.repos: @@ -361,8 +364,10 @@ def add_frontend_repo(self, url: List[str], user_id: int, group_name=None, group if group_id is None: return False, {"status": "Invalid group name"} - if not valid_repo and not self.is_valid_repo(url): - return False, {"status": "Invalid repo", "repo_url": url} + if not valid_repo: + result = self.is_valid_repo(url) + if not result[0]: + return False, {"status": result[1]["status"], "repo_url": url} repo_id = self.add_repo_row(url, DEFAULT_REPO_GROUP_IDS[0], "Frontend") if not repo_id: @@ -412,11 +417,11 @@ def add_frontend_org(self, url: List[str], user_id: int, group_name: int): if group_id is None: return False, {"status": "Invalid group name"} - repos = self.retrieve_org_repos(url) - - if not repos: - return False, {"status": "Invalid org", "org_url": url} + result = self.retrieve_org_repos(url) + if not result[0]: + return False, result[1] + repos = result[0] # try to get the repo group with this org name # if it does not exist create one failed_repos = [] @@ -425,7 +430,7 @@ def add_frontend_org(self, url: List[str], user_id: int, group_name: int): result = self.add_frontend_repo(repo, user_id, group_id=group_id, valid_repo=True) # keep track of all the repos that failed - if result["status"] != "Repo Added": + if not result[0]: failed_repos.append(repo) failed_count = len(failed_repos) @@ -445,7 +450,7 @@ def add_cli_repo(self, repo_data: Dict[str, Any], valid_repo=False): url = repo_data["url"] repo_group_id = repo_data["repo_group_id"] - if valid_repo or self.is_valid_repo(url): + if valid_repo or self.is_valid_repo(url)[0]: # if the repo doesn't exist it adds it # if the repo does exist it updates the repo_group_id From 03fd23ab01eaa00697c8af6947e47ac2f9073acb Mon Sep 17 00:00:00 2001 From: Andrew Brain Date: Tue, 10 Jan 2023 16:45:55 -0600 Subject: [PATCH 071/150] User function improvements Signed-off-by: Andrew Brain --- augur/util/repo_load_controller.py | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/augur/util/repo_load_controller.py b/augur/util/repo_load_controller.py index 99cce626ea..550ca41568 100644 --- a/augur/util/repo_load_controller.py +++ b/augur/util/repo_load_controller.py @@ -524,8 +524,6 @@ def get_user_repo_ids(self, user_id: int) -> List[int]: return list(all_repo_ids) - - def paginate_repos(self, source, page=0, page_size=25, sort="repo_id", direction="ASC", **kwargs): if not source: @@ -574,6 +572,9 @@ def paginate_repos(self, source, page=0, page_size=25, sort="repo_id", direction data = results.to_dict(orient="records") + for row in data: + row["repo_name"] = re.search(r"github\.com\/[A-Za-z0-9 \- _]+\/([A-Za-z0-9 \- _ .]+)$", row["url"]).groups()[0] + return data, {"status": "success"} def get_repo_count(self, source, **kwargs): @@ -595,8 +596,11 @@ def get_repo_count(self, source, **kwargs): if query[1]["status"] == "No data": return 0, {"status": "No data"} + + # surround query with count query so we just get the count of the rows + final_query = f"SELECT count(*) FROM ({query[0]}) a;" - get_page_of_repos_sql = s.sql.text(query[0]) + get_page_of_repos_sql = s.sql.text(final_query) result = self.session.fetchall_data_from_sql_text(get_page_of_repos_sql) @@ -606,10 +610,11 @@ def get_repo_count(self, source, **kwargs): def generate_repo_query(self, source, count, **kwargs): if count: - select = "count(*)" + # only query for repos ids so the query is faster for getting the count + select = " DISTINCT(augur_data.repo.repo_id)" else: - select = """ augur_data.repo.repo_id, - augur_data.repo.repo_name, + + select = """ DISTINCT(augur_data.repo.repo_id), augur_data.repo.description, augur_data.repo.repo_git AS url, augur_data.repo.repo_status, @@ -634,18 +639,12 @@ def generate_repo_query(self, source, count, **kwargs): print("Func: generate_repo_query. Error: User not passed when trying to get user repos") return None, {"status": "User not passed when trying to get user repos"} - group_ids = tuple(group.group_id for group in user.groups) - - if not group_ids: + if not user.groups: return None, {"status": "No data"} - if len(group_ids) == 1: - group_ids_str = str(group_ids)[:-2] + ")" - else: - group_ids_str = str(group_ids) - query += "\t\t JOIN augur_operations.user_repos ON augur_data.repo.repo_id = augur_operations.user_repos.repo_id\n" - query += f"\t\t WHERE augur_operations.user_repos.group_id in {group_ids_str}\n" + query += "\t\t JOIN augur_operations.user_groups ON augur_operations.user_repos.group_id = augur_operations.user_groups.group_id\n" + query += f"\t\t WHERE augur_operations.user_groups.user_id = {user.user_id}\n" elif source == "group": From eceab1baa4d425faa44c4cfa2e0e29b972e3420b Mon Sep 17 00:00:00 2001 From: Andrew Brain Date: Wed, 11 Jan 2023 08:13:20 -0600 Subject: [PATCH 072/150] Remove print Signed-off-by: Andrew Brain --- augur/api/view/augur_view.py | 1 - augur/util/repo_load_controller.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/augur/api/view/augur_view.py b/augur/api/view/augur_view.py index 79adad6334..56a562eb8e 100644 --- a/augur/api/view/augur_view.py +++ b/augur/api/view/augur_view.py @@ -71,7 +71,6 @@ def load_user_request(request): with DatabaseSession(logger) as session: token = session.query(UserSessionToken).filter(UserSessionToken.token == token).first() - print(token) if token: user = token.user diff --git a/augur/util/repo_load_controller.py b/augur/util/repo_load_controller.py index 550ca41568..9c0317cee6 100644 --- a/augur/util/repo_load_controller.py +++ b/augur/util/repo_load_controller.py @@ -262,7 +262,7 @@ def add_user_group(self, user_id:int, group_name:str) -> dict: "user_id": user_id } - user_group = self.session.query(UserGroup).filter(UserGroup.user_id == user_id, UserGroup, UserGroup.name == group_name).first() + user_group = self.session.query(UserGroup).filter(UserGroup.user_id == user_id, UserGroup.name == group_name).first() if user_group: return False, {"status": "Group already exists"} From a33e7ff25658763791cdd47d89eacc9a79f6b259 Mon Sep 17 00:00:00 2001 From: Isaac Milarsky Date: Wed, 11 Jan 2023 13:58:14 -0600 Subject: [PATCH 073/150] analysis sequence pooling for facade scaling Signed-off-by: Isaac Milarsky --- augur/tasks/git/facade_tasks.py | 146 +++++++++++++++++++------------- augur/tasks/util/worker_util.py | 2 + 2 files changed, 89 insertions(+), 59 deletions(-) diff --git a/augur/tasks/git/facade_tasks.py b/augur/tasks/git/facade_tasks.py index fbe4783b01..7ab3878b1c 100644 --- a/augur/tasks/git/facade_tasks.py +++ b/augur/tasks/git/facade_tasks.py @@ -70,17 +70,18 @@ def facade_analysis_init_facade_task(): session.log_activity('Info',f"Beginning analysis.") @celery.task -def grab_comitters(repo_id,platform="github"): +def grab_comitters(repo_id_list,platform="github"): logger = logging.getLogger(grab_comitters.__name__) - try: - grab_committer_list(GithubTaskSession(logger), repo_id,platform) - except Exception as e: - logger.error(f"Could not grab committers from github endpoint!\n Reason: {e} \n Traceback: {''.join(traceback.format_exception(None, e, e.__traceback__))}") - + for repo_id in repo_id_list: + try: + grab_committer_list(GithubTaskSession(logger), repo_id,platform) + except Exception as e: + logger.error(f"Could not grab committers from github endpoint!\n Reason: {e} \n Traceback: {''.join(traceback.format_exception(None, e, e.__traceback__))}") + @celery.task -def trim_commits_facade_task(repo_id): +def trim_commits_facade_task(repo_id_list): logger = logging.getLogger(trim_commits_facade_task.__name__) session = FacadeSession(logger) @@ -96,39 +97,39 @@ def update_analysis_log(repos_id,status): except: pass + for repo_id in repo_id_list: + session.inc_repos_processed() + update_analysis_log(repo_id,"Beginning analysis.") + # First we check to see if the previous analysis didn't complete - session.inc_repos_processed() - update_analysis_log(repo_id,"Beginning analysis.") - # First we check to see if the previous analysis didn't complete + get_status = s.sql.text("""SELECT working_commit FROM working_commits WHERE repos_id=:repo_id + """).bindparams(repo_id=repo_id) - get_status = s.sql.text("""SELECT working_commit FROM working_commits WHERE repos_id=:repo_id - """).bindparams(repo_id=repo_id) + try: + working_commits = session.fetchall_data_from_sql_text(get_status) + except: + working_commits = [] - try: - working_commits = session.fetchall_data_from_sql_text(get_status) - except: - working_commits = [] - - # If there's a commit still there, the previous run was interrupted and - # the commit data may be incomplete. It should be trimmed, just in case. - for commit in working_commits: - trim_commit(session, repo_id,commit['working_commit']) - - # Remove the working commit. - remove_commit = s.sql.text("""DELETE FROM working_commits - WHERE repos_id = :repo_id AND - working_commit = :commit""").bindparams(repo_id=repo_id,commit=commit['working_commit']) - session.execute_sql(remove_commit) - session.log_activity('Debug',f"Removed working commit: {commit['working_commit']}") - - # Start the main analysis + # If there's a commit still there, the previous run was interrupted and + # the commit data may be incomplete. It should be trimmed, just in case. + for commit in working_commits: + trim_commit(session, repo_id,commit['working_commit']) - update_analysis_log(repo_id,'Collecting data') - logger.info(f"Got past repo {repo_id}") + # Remove the working commit. + remove_commit = s.sql.text("""DELETE FROM working_commits + WHERE repos_id = :repo_id AND + working_commit = :commit""").bindparams(repo_id=repo_id,commit=commit['working_commit']) + session.execute_sql(remove_commit) + session.log_activity('Debug',f"Removed working commit: {commit['working_commit']}") + + # Start the main analysis + + update_analysis_log(repo_id,'Collecting data') + logger.info(f"Got past repo {repo_id}") @celery.task -def trim_commits_post_analysis_facade_task(repo_id,commits): +def trim_commits_post_analysis_facade_task(commits): logger = logging.getLogger(trim_commits_post_analysis_facade_task.__name__) session = FacadeSession(logger) @@ -142,24 +143,32 @@ def update_analysis_log(repos_id,status): session.execute_sql(log_message) + repo_ids = [] - update_analysis_log(repo_id,'Data collection complete') - - update_analysis_log(repo_id,'Beginning to trim commits') - - session.log_activity('Debug',f"Commits to be trimmed from repo {repo_id}: {len(commits)}") - for commit in commits: - trim_commit(session,repo_id,commit) - set_complete = s.sql.text("""UPDATE repo SET repo_status='Complete' WHERE repo_id=:repo_id and repo_status != 'Empty' - """).bindparams(repo_id=repo_id) + for commit in commits: + repo_id = commit[1] + if repo_id not in repo_ids: + update_analysis_log(repo_id,'Data collection complete') + + update_analysis_log(repo_id,'Beginning to trim commits') + + session.log_activity('Debug',f"Commits to be trimmed from repo {repo_id}: {len(commits)}") + + repo_ids.append(repo_id) + + trim_commit(session,repo_id,commit[0]) - session.execute_sql(set_complete) + for repo_id in repo_ids: + set_complete = s.sql.text("""UPDATE repo SET repo_status='Complete' WHERE repo_id=:repo_id and repo_status != 'Empty' + """).bindparams(repo_id=repo_id) + + session.execute_sql(set_complete) - update_analysis_log(repo_id,'Commit trimming complete') + update_analysis_log(repo_id,'Commit trimming complete') - update_analysis_log(repo_id,'Complete') + update_analysis_log(repo_id,'Complete') @celery.task def facade_analysis_end_facade_task(): @@ -178,20 +187,26 @@ def facade_start_contrib_analysis_task(): #enable celery multithreading @celery.task -def analyze_commits_in_parallel(queue: list, repo_id: int, repo_location: str, multithreaded: bool)-> None: +def analyze_commits_in_parallel(queue: list, multithreaded: bool)-> None: """Take a large list of commit data to analyze and store in the database. Meant to be run in parallel with other instances of this task. """ #create new session for celery thread. logger = logging.getLogger(analyze_commits_in_parallel.__name__) - session = FacadeSession(logger) logger.info(f"Got to analysis!") - for analyzeCommit in queue: + for commitTuple in queue: + session = FacadeSession(logger) + + session.query(Repo).filter(Repo.repo_id == commitTuple[1]) + repo = execute_session_query(query,'one') + + + repo_loc = (f"{session.repo_base_directory}{repo.repo_group_id}/{repo.repo_path}{repo.repo_name}/.git") + + analyze_commit(session, repo_id, repo_loc, commitTuple[0]) - analyze_commit(session, repo_id, repo_location, analyzeCommit) - logger.info("Analysis complete") @celery.task @@ -241,12 +256,20 @@ def generate_analysis_sequence(logger): start_date = session.get_setting('start_date') + repo_ids = [repo['repo_id'] for repo in repos] + analysis_sequence.append(facade_analysis_init_facade_task.si().on_error(facade_error_handler.s())) + + analysis_sequence.append(create_grouped_task_load(dataList=repo_ids,task=grab_comitters).link_error(facade_error_handler.s())) + + analysis_sequence.append(create_grouped_task_load(dataList=repo_ids,task=trim_commits_facade_task).link_error(facade_error_handler.s())) + + all_missing_commits = [] + all_trimmed_commits = [] + + for repo in repos: session.logger.info(f"Generating sequence for repo {repo['repo_id']}") - analysis_sequence.append(grab_comitters.si(repo['repo_id']).on_error(facade_error_handler.s())) - #grab_comitters.si(repo.repo_id), - analysis_sequence.append(trim_commits_facade_task.si(repo['repo_id'])) #Get the huge list of commits to process. @@ -289,17 +312,22 @@ def generate_analysis_sequence(logger): if len(missing_commits) > 0: #session.log_activity('Info','Type of missing_commits: %s' % type(missing_commits)) - #Split commits into mostly equal queues so each process starts with a workload and there is no - # overhead to pass into queue from the parent. - contrib_jobs = create_grouped_task_load(repo['repo_id'],repo_loc,True,dataList=list(missing_commits),task=analyze_commits_in_parallel) - contrib_jobs.link_error(facade_error_handler.s()) - analysis_sequence.append(contrib_jobs) + #encode the repo_id with the commit. + commits_with_repo_tuple = [(commit,repo['repo_id']) for commit in list(missing_commits)] + #Get all missing commits into one large list to split into task pools + all_missing_commits.extend(commits_with_repo_tuple) # Find commits which are out of the analysis range trimmed_commits = existing_commits - parent_commits - analysis_sequence.append(trim_commits_post_analysis_facade_task.si(repo['repo_id'],list(trimmed_commits))) + + trimmed_commits_with_repo_tuple = [(commit,repo['repo_id']) for commit in list(trimmed_commits)] + all_trimmed_commits.extend(trimmed_commits_with_repo_tuple) + + analysis_sequence.append(create_grouped_task_load(True,dataList=all_missing_commits,task=analyze_commits_in_parallel).link_error(facade_error_handler.s())) + + analysis_sequence.append(create_grouped_task_load(dataList=all_trimmed_commits,task=trim_commits_post_analysis_facade_task).link_error(facade_error_handler.s())) analysis_sequence.append(facade_analysis_end_facade_task.si().on_error(facade_error_handler.s())) #print(f"Analysis sequence: {analysis_sequence}") diff --git a/augur/tasks/util/worker_util.py b/augur/tasks/util/worker_util.py index 8efac1db7b..427017143a 100644 --- a/augur/tasks/util/worker_util.py +++ b/augur/tasks/util/worker_util.py @@ -29,6 +29,8 @@ def create_grouped_task_load(*args,processes=8,dataList=[],task=None): return jobs + + def wait_child_tasks(ids_list): for task_id in ids_list: prereq = AsyncResult(str(task_id)) From 81dfe6601e2de7edbed219175c3b540917d9d109 Mon Sep 17 00:00:00 2001 From: Isaac Milarsky Date: Wed, 11 Jan 2023 16:01:13 -0600 Subject: [PATCH 074/150] need to fix issues with accessing redis Signed-off-by: Isaac Milarsky --- augur/tasks/git/facade_tasks.py | 22 +++++++++++++--------- augur/tasks/github/releases/core.py | 2 +- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/augur/tasks/git/facade_tasks.py b/augur/tasks/git/facade_tasks.py index 7ab3878b1c..9750c307a5 100644 --- a/augur/tasks/git/facade_tasks.py +++ b/augur/tasks/git/facade_tasks.py @@ -199,13 +199,13 @@ def analyze_commits_in_parallel(queue: list, multithreaded: bool)-> None: for commitTuple in queue: session = FacadeSession(logger) - session.query(Repo).filter(Repo.repo_id == commitTuple[1]) + query = session.query(Repo).filter(Repo.repo_id == commitTuple[1]) repo = execute_session_query(query,'one') repo_loc = (f"{session.repo_base_directory}{repo.repo_group_id}/{repo.repo_path}{repo.repo_name}/.git") - analyze_commit(session, repo_id, repo_loc, commitTuple[0]) + analyze_commit(session, commitTuple[1], repo_loc, commitTuple[0]) logger.info("Analysis complete") @@ -258,11 +258,11 @@ def generate_analysis_sequence(logger): repo_ids = [repo['repo_id'] for repo in repos] - analysis_sequence.append(facade_analysis_init_facade_task.si().on_error(facade_error_handler.s())) + analysis_sequence.append(facade_analysis_init_facade_task.si()) - analysis_sequence.append(create_grouped_task_load(dataList=repo_ids,task=grab_comitters).link_error(facade_error_handler.s())) + analysis_sequence.append(create_grouped_task_load(dataList=repo_ids,task=grab_comitters)) - analysis_sequence.append(create_grouped_task_load(dataList=repo_ids,task=trim_commits_facade_task).link_error(facade_error_handler.s())) + analysis_sequence.append(create_grouped_task_load(dataList=repo_ids,task=trim_commits_facade_task)) all_missing_commits = [] all_trimmed_commits = [] @@ -325,12 +325,15 @@ def generate_analysis_sequence(logger): all_trimmed_commits.extend(trimmed_commits_with_repo_tuple) - analysis_sequence.append(create_grouped_task_load(True,dataList=all_missing_commits,task=analyze_commits_in_parallel).link_error(facade_error_handler.s())) + if all_missing_commits: + analysis_sequence.append(create_grouped_task_load(True,dataList=all_missing_commits,task=analyze_commits_in_parallel)) - analysis_sequence.append(create_grouped_task_load(dataList=all_trimmed_commits,task=trim_commits_post_analysis_facade_task).link_error(facade_error_handler.s())) - analysis_sequence.append(facade_analysis_end_facade_task.si().on_error(facade_error_handler.s())) + if all_trimmed_commits: + analysis_sequence.append(create_grouped_task_load(dataList=all_trimmed_commits,task=trim_commits_post_analysis_facade_task)) + + analysis_sequence.append(facade_analysis_end_facade_task.si()) - #print(f"Analysis sequence: {analysis_sequence}") + logger.info(f"Analysis sequence: {analysis_sequence}") return analysis_sequence @@ -420,5 +423,6 @@ def generate_facade_chain(logger): if not limited_run or (limited_run and rebuild_caches): facade_sequence.append(rebuild_unknown_affiliation_and_web_caches_facade_task.si().on_error(facade_error_handler.s()))#rebuild_unknown_affiliation_and_web_caches(session.cfg) + #logger.info(f"Facade sequence: {facade_sequence}") return chain(*facade_sequence) diff --git a/augur/tasks/github/releases/core.py b/augur/tasks/github/releases/core.py index c4fdd96782..093a899a02 100644 --- a/augur/tasks/github/releases/core.py +++ b/augur/tasks/github/releases/core.py @@ -184,7 +184,7 @@ def releases_model(session, repo_git, repo_id): session.logger.info(f"Ran into problem when fetching data for repo {repo_git}: {e}") return - session.logger.info("repository value is: {}\n".format(data)) + #session.logger.info("repository value is: {}\n".format(data)) if 'releases' in data: if 'edges' in data['releases'] and data['releases']['edges']: for n in data['releases']['edges']: From 452bb358c147af215197e52ef8396c22571d7132 Mon Sep 17 00:00:00 2001 From: Isaac Milarsky Date: Wed, 11 Jan 2023 17:04:59 -0600 Subject: [PATCH 075/150] don't create so many sessions Signed-off-by: Isaac Milarsky --- augur/tasks/git/facade_tasks.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/augur/tasks/git/facade_tasks.py b/augur/tasks/git/facade_tasks.py index 9750c307a5..c1cbbef7a7 100644 --- a/augur/tasks/git/facade_tasks.py +++ b/augur/tasks/git/facade_tasks.py @@ -195,9 +195,9 @@ def analyze_commits_in_parallel(queue: list, multithreaded: bool)-> None: logger = logging.getLogger(analyze_commits_in_parallel.__name__) logger.info(f"Got to analysis!") - + session = FacadeSession(logger) for commitTuple in queue: - session = FacadeSession(logger) + query = session.query(Repo).filter(Repo.repo_id == commitTuple[1]) repo = execute_session_query(query,'one') From 0425666d63bb99dfd3f5521674f21e15e6f45ea8 Mon Sep 17 00:00:00 2001 From: Andrew Brain Date: Thu, 12 Jan 2023 09:49:02 -0600 Subject: [PATCH 076/150] Add database changes and fixes to the api Signed-off-by: Andrew Brain --- augur/api/routes/user.py | 41 ++++++++++++++++--- .../application/db/models/augur_operations.py | 37 +++++++++++++++++ .../versions/2_added_user_groups_and_login.py | 21 ++++++++++ 3 files changed, 94 insertions(+), 5 deletions(-) diff --git a/augur/api/routes/user.py b/augur/api/routes/user.py index a011c64163..e5b35264d6 100644 --- a/augur/api/routes/user.py +++ b/augur/api/routes/user.py @@ -296,15 +296,13 @@ def remove_user_repo(): return jsonify(result[1]) - @server.app.route(f"/{AUGUR_API_VERSION}/user/group/repos", methods=['GET', 'POST']) + @server.app.route(f"/{AUGUR_API_VERSION}/user/group/repos/", methods=['GET', 'POST']) @login_required def group_repos(): """Select repos from a user group by name Arguments ---------- - username : str - The username of the user making the request group_name : str The name of the group to select page : int = 0 -> [>= 0] @@ -333,9 +331,14 @@ def group_repos(): result = current_user.get_group_repos(group_name, page, page_size, sort, direction) + result_dict = result[1] if result[0] is not None: - result_dict.update({"repos": result[0]}) + + for repo in result[0]: + repo["base64_url"] = str(repo["base64_url"].decode()) + + result_dict.update({"repos": result[0]}) return jsonify(result_dict) @@ -370,7 +373,7 @@ def group_repo_count(): return jsonify(result_dict) - @server.app.route(f"/{AUGUR_API_VERSION}/user/groups", methods=['GET', 'POST']) + @server.app.route(f"/{AUGUR_API_VERSION}/user/groups/names", methods=['GET', 'POST']) @login_required def get_user_groups(): """Get a list of user groups by username @@ -393,4 +396,32 @@ def get_user_groups(): return jsonify({"status": "success", "group_names": result[0]}) + @server.app.route(f"/{AUGUR_API_VERSION}/user/groups/repos/ids", methods=['GET', 'POST']) + @login_required + def get_user_groups_and_repos(): + """Get a list of user groups and their repos + + Returns + ------- + list + A list with this strucutre : [{"": Date: Thu, 12 Jan 2023 11:14:05 -0500 Subject: [PATCH 077/150] Added refresh endpoint Signed-off-by: Ulincsys --- augur/api/routes/user.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/augur/api/routes/user.py b/augur/api/routes/user.py index e5b35264d6..b68408cb9c 100644 --- a/augur/api/routes/user.py +++ b/augur/api/routes/user.py @@ -148,6 +148,14 @@ def generate_session(application): return jsonify({"status": "Validated", "username": username, "access_token": user_session_token, "token_type": "Bearer", "expires": seconds_to_expire}) + @server.app.route(f"/{AUGUR_API_VERSION}/user/session/refresh") + @api_key_required + def refresh_session(): + refresh_token = request.args.get("refresh_token") + + if not refresh_token: + return jsonify({"status": "Invalid refresh token"}) + @server.app.route(f"/{AUGUR_API_VERSION}/user/query", methods=['POST']) def query_user(): if not development and not request.is_secure: From feea0fb292d0c5002d2d1297777725c2477a3873 Mon Sep 17 00:00:00 2001 From: Isaac Milarsky Date: Thu, 12 Jan 2023 10:23:24 -0600 Subject: [PATCH 078/150] Update Signed-off-by: Isaac Milarsky --- augur/tasks/data_analysis/__init__.py | 13 +++++++------ .../facade_worker/facade03analyzecommit.py | 2 +- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/augur/tasks/data_analysis/__init__.py b/augur/tasks/data_analysis/__init__.py index 69c2e690d8..3324137668 100644 --- a/augur/tasks/data_analysis/__init__.py +++ b/augur/tasks/data_analysis/__init__.py @@ -1,9 +1,3 @@ -from augur.tasks.data_analysis.clustering_worker.tasks import clustering_model -from augur.tasks.data_analysis.contributor_breadth_worker.contributor_breadth_worker import contributor_breadth_model -from augur.tasks.data_analysis.discourse_analysis.tasks import discourse_analysis_model -from augur.tasks.data_analysis.insight_worker.tasks import insight_model -from augur.tasks.data_analysis.message_insights.tasks import message_insight_model -from augur.tasks.data_analysis.pull_request_analysis_worker.tasks import pull_request_analysis_model from augur.application.db.session import DatabaseSession from augur.application.db.models import Repo from augur.application.db.util import execute_session_query @@ -13,6 +7,13 @@ @celery.task def machine_learning_phase(): + from augur.tasks.data_analysis.clustering_worker.tasks import clustering_model + from augur.tasks.data_analysis.contributor_breadth_worker.contributor_breadth_worker import contributor_breadth_model + from augur.tasks.data_analysis.discourse_analysis.tasks import discourse_analysis_model + from augur.tasks.data_analysis.insight_worker.tasks import insight_model + from augur.tasks.data_analysis.message_insights.tasks import message_insight_model + from augur.tasks.data_analysis.pull_request_analysis_worker.tasks import pull_request_analysis_model + logger = logging.getLogger(machine_learning_phase.__name__) diff --git a/augur/tasks/git/util/facade_worker/facade_worker/facade03analyzecommit.py b/augur/tasks/git/util/facade_worker/facade_worker/facade03analyzecommit.py index d6914cb97f..8aecdbd829 100644 --- a/augur/tasks/git/util/facade_worker/facade_worker/facade03analyzecommit.py +++ b/augur/tasks/git/util/facade_worker/facade_worker/facade03analyzecommit.py @@ -165,7 +165,7 @@ def store_commit(repos_id,commit,filename, raise e - session.log_activity('Debug',f"Stored commit: {commit}") + #session.log_activity('Debug',f"Stored commit: {commit}") ### The real function starts here ### From c4e7c701d10608bbbdd6cc610a679c69f19b1c46 Mon Sep 17 00:00:00 2001 From: Andrew Brain Date: Thu, 12 Jan 2023 10:59:48 -0600 Subject: [PATCH 079/150] Add code for refresh tokens Signed-off-by: Andrew Brain --- augur/api/routes/user.py | 24 +++++++++- .../application/db/models/augur_operations.py | 46 +++++++++++++++++-- .../versions/2_added_user_groups_and_login.py | 12 +++++ 3 files changed, 75 insertions(+), 7 deletions(-) diff --git a/augur/api/routes/user.py b/augur/api/routes/user.py index b68408cb9c..50dfd1bce3 100644 --- a/augur/api/routes/user.py +++ b/augur/api/routes/user.py @@ -23,7 +23,7 @@ from augur.api.util import get_bearer_token from augur.api.util import get_client_token -from augur.application.db.models import User, UserRepo, UserGroup, UserSessionToken, ClientApplication +from augur.application.db.models import User, UserRepo, UserGroup, UserSessionToken, ClientApplication, RefreshToken from augur.application.config import get_development_flag from augur.tasks.init.redis_connection import redis_connection as redis @@ -151,10 +151,30 @@ def generate_session(application): @server.app.route(f"/{AUGUR_API_VERSION}/user/session/refresh") @api_key_required def refresh_session(): - refresh_token = request.args.get("refresh_token") + refresh_token_str = request.args.get("refresh_token") + if not refresh_token_str: + return jsonify({"status": "Invalid refresh token"}) + + session = Session() + refresh_token = session.query(RefreshToken).filter(RefreshToken.id == refresh_token_str).first() if not refresh_token: return jsonify({"status": "Invalid refresh token"}) + + user_session = refresh_token.user_session + user = user_session.user + + new_user_session = UserSessionToken.create(user.user_id, user_session.application.id) + new_refresh_token = RefreshToken.create(new_user_session.token) + + if refresh_token.delete() != 1: + return jsonify({"status": "Error deleting refresh token"}) + + if user_session.delete() != 1: + return jsonify({"status": "Error deleting user session"}) + + return jsonify({"refresh_token": new_refresh_token.id, "session": new_user_session.token}) + @server.app.route(f"/{AUGUR_API_VERSION}/user/query", methods=['POST']) def query_user(): diff --git a/augur/application/db/models/augur_operations.py b/augur/application/db/models/augur_operations.py index 9ef2cc75c2..05f8c45786 100644 --- a/augur/application/db/models/augur_operations.py +++ b/augur/application/db/models/augur_operations.py @@ -539,11 +539,6 @@ def add_app(self, name, redirect_url): return True - - - - - class UserGroup(Base): group_id = Column(BigInteger, primary_key=True) user_id = Column(Integer, @@ -597,6 +592,21 @@ class UserSessionToken(Base): user = relationship("User") application = relationship("ClientApplication") + @staticmethod + def create(user_id, application_id): + import time + + user_session_token = secrets.token_hex() + seconds_to_expire = 86400 + expiration = int(time.time()) + seconds_to_expire + + local_session = get_session() + user_session = UserSessionToken(token=user_session_token, user_id=user_id, application_id = application_id, expiration=expiration) + + local_session.add(user_session) + local_session.commit() + + return user_session class ClientApplication(Base): __tablename__ = "client_applications" @@ -652,6 +662,32 @@ class SubscriptionType(Base): subscriptions = relationship("Subscription") +class RefreshToken(Base): + __tablename__ = "refresh_tokens" + __table_args__ = ( + UniqueConstraint('user_session_token', name='refresh_token_user_session_token_id_unique'), + {"schema": "augur_operations"} + ) + + id = Column(String, primary_key=True) + user_session_token = Column(ForeignKey("augur_operations.user_session_tokens.token", name="refresh_token_session_token_id_fkey"), nullable=False) + + user_session = relationship("UserSessionToken") + + @staticmethod + def create(user_session_token_id): + + refresh_token_id = secrets.token_hex() + + local_session = get_session() + refresh_token = RefreshToken(id=refresh_token_id, user_session_token=user_session_token_id) + + local_session.add(refresh_token) + local_session.commit() + + return refresh_token + + diff --git a/augur/application/schema/alembic/versions/2_added_user_groups_and_login.py b/augur/application/schema/alembic/versions/2_added_user_groups_and_login.py index 1fac147a10..009268ab6e 100644 --- a/augur/application/schema/alembic/versions/2_added_user_groups_and_login.py +++ b/augur/application/schema/alembic/versions/2_added_user_groups_and_login.py @@ -143,12 +143,24 @@ def upgrade(): ) op.add_column('user_groups', sa.Column('favorited', sa.Boolean(), server_default=sa.text('FALSE'), nullable=False), schema='augur_operations') + + + op.create_table('refresh_tokens', + sa.Column('id', sa.String(), nullable=False), + sa.Column('user_session_token', sa.String(), nullable=False), + sa.ForeignKeyConstraint(['user_session_token'], ['augur_operations.user_session_tokens.token'], name='refresh_token_session_token_id_fkey'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('user_session_token', name='refresh_token_user_session_token_id_unique'), + schema='augur_operations' + ) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('refresh_tokens', schema='augur_operations') + op.drop_column('user_groups', 'favorited', schema='augur_operations') op.drop_table('subscriptions', schema='augur_operations') From e4b30a9c5b54f4fb9bb8f056c720e553d76e8f82 Mon Sep 17 00:00:00 2001 From: Ulincsys Date: Thu, 12 Jan 2023 12:46:59 -0500 Subject: [PATCH 080/150] Update auth requirements Signed-off-by: Ulincsys --- augur/api/routes/user.py | 18 ++++++++++++++---- augur/templates/settings.j2 | 12 ++++++++++++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/augur/api/routes/user.py b/augur/api/routes/user.py index 50dfd1bce3..a598fd552a 100644 --- a/augur/api/routes/user.py +++ b/augur/api/routes/user.py @@ -122,9 +122,11 @@ def user_authorize(): @server.app.route(f"/{AUGUR_API_VERSION}/user/session/generate", methods=['POST']) @api_key_required def generate_session(application): - code = request.args.get("code") - if not code: + if not (code := request.args.get("code")): return jsonify({"status": "Invalid authorization code"}) + + if request.args.get("grant_type") != "code": + return jsonify({"status": "Invalid grant type"}) username = redis.get(code) redis.delete(code) @@ -142,11 +144,16 @@ def generate_session(application): session = Session() user_session = UserSessionToken(token=user_session_token, user_id=user.user_id, application_id = application.id, expiration=expiration) + refresh_token = RefreshToken.create(user_session_token) + session.add(user_session) session.commit() session.close() - - return jsonify({"status": "Validated", "username": username, "access_token": user_session_token, "token_type": "Bearer", "expires": seconds_to_expire}) + + response = jsonify({"status": "Validated", "username": username, "access_token": user_session_token, "refresh_token" : refresh_token.id, "token_type": "Bearer", "expires": seconds_to_expire}) + response.headers["Cache-Control"] = "no-store" + + return response @server.app.route(f"/{AUGUR_API_VERSION}/user/session/refresh") @api_key_required @@ -155,6 +162,9 @@ def refresh_session(): if not refresh_token_str: return jsonify({"status": "Invalid refresh token"}) + + if request.args.get("grant_type") != "refresh_token": + return jsonify({"status": "Invalid grant type"}) session = Session() refresh_token = session.query(RefreshToken).filter(RefreshToken.id == refresh_token_str).first() diff --git a/augur/templates/settings.j2 b/augur/templates/settings.j2 index aa09f265fe..eb29a2798e 100644 --- a/augur/templates/settings.j2 +++ b/augur/templates/settings.j2 @@ -21,6 +21,7 @@ + Settings - Augur View @@ -128,6 +129,7 @@ {%- set tableHeaders = [{"title" : "Group"}, {"title" : "Number of repos"}, + {"title" : "Favorite"} {"title" : ""}] -%}
@@ -146,6 +148,7 @@ {{ group.name }} {{ group.repos | length }} + {% endfor %} @@ -370,6 +373,15 @@ button.classList.add("btn-outline-danger"); } } + var toggleFavorite = function(button) { + if(button.classList.contains("bi-star-fill")) { + button.classList.remove("bi-star-fill"); + button.classList.add("bi-star"); + } else { + button.classList.remove("bi-star"); + button.classList.add("bi-star-fill"); + } + }