diff --git a/.github/workflows/app1.yml b/.github/workflows/app1.yml index e7d2d0b9..3b79592e 100644 --- a/.github/workflows/app1.yml +++ b/.github/workflows/app1.yml @@ -41,4 +41,4 @@ jobs: yarn build systemctl restart upstage.service systemctl restart event_archive.service - chown -R upstage:upstage /home/upstage/upstage \ No newline at end of file + chown -R upstage:upstage /home/upstage/upstage diff --git a/.github/workflows/devapp1.yml b/.github/workflows/devapp1.yml index 204345a6..f81b3fa4 100644 --- a/.github/workflows/devapp1.yml +++ b/.github/workflows/devapp1.yml @@ -6,7 +6,7 @@ name: DEVAPP1 CI on: # Triggers the workflow on push or approved pull request on R1-2021 branch push: - branches: [ MB-13 ] + branches: [ TC-14 ] # Allows you to run this workflow manually from the Actions tab workflow_dispatch: @@ -30,15 +30,15 @@ jobs: key: ${{ secrets.DEVAPP1_KEY }} port: ${{ secrets.DEVAPP1_PORT }} script: | - cd /home/upstage/mobilise/ui/dashboard/ + cd /home/upstage/upstage/ui/dashboard/ git fetch - git checkout MB-13 + git checkout TC-14 git pull yarn yarn build:dev - cd /home/upstage/mobilise/ui/studio/ + cd /home/upstage/upstage/ui/studio/ yarn yarn build:dev systemctl restart upstage.service systemctl restart event_archive.service - chown -R upstage:upstage /home/upstage/mobilise + chown -R upstage:upstage /home/upstage/upstage diff --git a/.github/workflows/meta.yml b/.github/workflows/meta.yml new file mode 100644 index 00000000..58f9eae0 --- /dev/null +++ b/.github/workflows/meta.yml @@ -0,0 +1,44 @@ +# This is a basic workflow to help you get started with Actions + +name: CI + +# Controls when the action will run. +on: + # Triggers the workflow on push or approved pull request on R1-2021 branch + push: + branches: [ main ] + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +# A workflow run is made up of one or more jobs that can run sequentially or in parallel +jobs: + # This workflow contains a single job called "build" + build: + # The type of runner that the job will run on + runs-on: ubuntu-latest + + # Steps represent a sequence of tasks that will be executed as part of the job + steps: + - name: SSH Remote Commands + # You may pin to the exact commit or the version. + # uses: appleboy/ssh-action@1d1b21ca96111b1eb4c03c21c14ebb971d2200f6 + uses: appleboy/ssh-action@v0.1.4 + with: + host: ${{ secrets.META_HOST }} + username: ${{ secrets.META_USERNAME }} + key: ${{ secrets.META_KEY }} + port: ${{ secrets.META_PORT }} + script: | + cd /home/upstage/upstage/ui/dashboard/ + git fetch + git checkout main + git pull + yarn + yarn build + cd /home/upstage/upstage/ui/studio/ + yarn + yarn build + systemctl restart upstage.service + systemctl restart event_archive.service + chown -R upstage:upstage /home/upstage/upstage diff --git a/asset/models.py b/asset/models.py index 2ea2f182..aafa650f 100644 --- a/asset/models.py +++ b/asset/models.py @@ -41,11 +41,13 @@ class Asset(Base, db.Model): created_on = Column(DateTime, nullable=False, default=datetime.utcnow) updated_on = Column(DateTime, nullable=False, default=datetime.utcnow) size = Column(BigInteger, nullable=False, default=0) + copyright_level = Column(Integer, nullable=False, default=0) asset_type = relationship(AssetType, foreign_keys=[asset_type_id]) asset_license = relationship(AssetLicense, uselist=False, backref="asset") owner = relationship(User, foreign_keys=[owner_id]) - stages = relationship('ParentStage', lazy='dynamic') - tags = relationship('MediaTag', lazy='dynamic') + stages = relationship('ParentStage', lazy='dynamic', back_populates='child_asset') + tags = relationship('MediaTag', lazy='dynamic', back_populates='asset') + permissions = relationship('AssetUsage', lazy='dynamic', back_populates='asset') class Stage(Base, db.Model): @@ -61,8 +63,8 @@ class Stage(Base, db.Model): file_location = Column(Text, nullable=False) created_on = Column(DateTime, nullable=False, default=datetime.utcnow) owner = relationship(User, foreign_keys=[owner_id]) - attributes = relationship(lambda: StageAttribute, lazy='dynamic') - assets = relationship('ParentStage', lazy='dynamic') + attributes = relationship(lambda: StageAttribute, lazy='dynamic', back_populates='stage') + assets = relationship('ParentStage', lazy='dynamic', back_populates='stage') class AssetAttribute(Base, db.Model): @@ -88,7 +90,7 @@ class StageAttribute(Base, db.Model): name = Column(String, nullable=False) description = Column(Text, nullable=False) created_on = Column(DateTime, nullable=False, default=datetime.utcnow) - stage = relationship(Stage, foreign_keys=[stage_id]) + stage = relationship(Stage, foreign_keys=[stage_id], back_populates="attributes") class Tag(Base, db.Model): @@ -104,5 +106,5 @@ class MediaTag(Base, db.Model): id = Column(BigInteger, primary_key=True) asset_id = Column(Integer, ForeignKey(Asset.id), nullable=False, default=0) tag_id = Column(Integer, ForeignKey(Tag.id), nullable=False, default=0) - asset = relationship(Asset, foreign_keys=[asset_id]) + asset = relationship(Asset, foreign_keys=[asset_id], back_populates="tags") tag = relationship(Tag, foreign_keys=[tag_id]) diff --git a/asset/sqlfiles/create.sql b/asset/sqlfiles/create.sql index 8465900f..d0ed3c8d 100644 --- a/asset/sqlfiles/create.sql +++ b/asset/sqlfiles/create.sql @@ -16,6 +16,7 @@ CREATE TABLE "public"."asset" ( "owner_id" integer NOT NULL, "description" TEXT default NULL, "file_location" TEXT NOT NULL, + "copyright_level" integer NOT NULL default 0, "created_on" timestamp DEFAULT (now() at time zone 'utc'), "updated_on" timestamp DEFAULT (now() at time zone 'utc'), "size" BIGINT NOT NULL DEFAULT 0, diff --git a/config/schema.py b/config/schema.py index 7802f97a..b9bfe46c 100644 --- a/config/schema.py +++ b/config/schema.py @@ -1,6 +1,6 @@ # -*- coding: iso8859-15 -*- from os import name -from config.project_globals import ScopedSession, app +from config.project_globals import DBSession, ScopedSession, app from flask_graphql import GraphQLView from config.settings import NGINX_CONFIG_FILE, VERSION from graphene import relay @@ -28,22 +28,40 @@ def resolve_uploadLimit(self, info): return limit +def get_config(name): + config = DBSession.query(ConfigModel).filter( + ConfigModel.name == name).first() + if config: + return config.value + + class SystemConfig(graphene.ObjectType): termsOfService = graphene.String() def resolve_termsOfService(self, info): - with ScopedSession() as local_db_session: - config = local_db_session.query(ConfigModel).filter( - ConfigModel.name == TERMS_OF_SERVICE - ).first() - if config: - return config.value + return get_config(TERMS_OF_SERVICE) + + +class FoyerConfig(graphene.ObjectType): + title = graphene.String() + description = graphene.String() + menu = graphene.String() + + def resolve_title(self, info): + return get_config('FOYER_TITLE') + + def resolve_description(self, info): + return get_config('FOYER_DESCRIPTION') + + def resolve_menu(self, info): + return get_config('FOYER_MENU') class Query(graphene.ObjectType): node = relay.Node.Field() nginx = graphene.Field(NginxConfig) system = graphene.Field(SystemConfig) + foyer = graphene.Field(FoyerConfig) def resolve_nginx(self, info): return NginxConfig() @@ -51,6 +69,9 @@ def resolve_nginx(self, info): def resolve_system(self, info): return SystemConfig() + def resolve_foyer(self, info): + return FoyerConfig() + class UpdateTermsOfService(graphene.Mutation): """Mutation to update Terms of Service's URL.""" @@ -76,8 +97,38 @@ def mutate(self, info, url): return UpdateTermsOfService(url=url) +class SaveConfig(graphene.Mutation): + """Mutation to customise foyer.""" + success = graphene.Boolean(description="True if the config was saved.") + + class Arguments: + name = graphene.String( + required=True, description="The name of the config.") + value = graphene.String( + required=True, description="The value of the config.") + + # decorate this with jwt login decorator. + def mutate(self, info, name, value): + with ScopedSession() as local_db_session: + config = local_db_session.query(ConfigModel).filter( + ConfigModel.name == name).first() + + if name == TERMS_OF_SERVICE: + # Use UpdateTermsOfService mutation instead. + return SaveConfig(success=False) + + if config: + config.value = value + else: + config = ConfigModel(name=name, value=value) + local_db_session.add(config) + + return SaveConfig(success=True) + + class Mutation(graphene.ObjectType): updateTermsOfService = UpdateTermsOfService.Field() + saveConfig = SaveConfig.Field() # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/config/sqlfiles/create.sql b/config/sqlfiles/create.sql index add9830f..962698c8 100644 --- a/config/sqlfiles/create.sql +++ b/config/sqlfiles/create.sql @@ -6,3 +6,9 @@ CREATE TABLE "public"."config" ( "created_on" timestamp DEFAULT (now() at time zone 'utc'), PRIMARY KEY ("id") ); + +-- Seed foyer config +INSERT INTO "config" ("name", "value") VALUES ('FOYER_TITLE', 'CYBERFORMANCE PLATFORM'); +INSERT INTO "config" ("name", "value") VALUES ('FOYER_DESCRIPTION', 'UpStage is an online venue for live performance: remote performers collaborate in real time using digital media, and online audiences anywhere in the world join events by going to a web page, without having to download and install any additional software. UpStage is available free to anyone who would like to use it.'); +INSERT INTO "config" ("name", "value") VALUES ('FOYER_MENU', 'UpStage User Manual (https://docs.upstage.live/) +Customise Foyer (/backstage/admin/foyer-customisation)'); \ No newline at end of file diff --git a/docs/conf.py b/docs/conf.py index 14a7fd23..d0ae7134 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -56,7 +56,7 @@ "description": "UpStage is a platform for cyberformance: remote players combine digital media in real-time for an online audience. All you need is a web browser!", "logo": "logo.svg", "github_user": "upstage-org", - "github_repo": "mobilise", + "github_repo": "upstage", "github_banner": "true", "page_width": "90%", "donate_url": "https://upstage.org.nz/?page_id=278", diff --git a/licenses/models.py b/licenses/models.py index 8274a04b..b9d488a3 100644 --- a/licenses/models.py +++ b/licenses/models.py @@ -38,3 +38,15 @@ class AssetLicense(Base,db.Model): created_on = Column(DateTime, nullable=False, default=datetime.utcnow) level = Column(Integer, nullable=False, default=0) permissions = Column(String, nullable=True) + +class AssetUsage(Base,db.Model): + __tablename__ = "asset_usage" + id = Column(BigInteger, primary_key=True) + asset_id = Column(Integer, ForeignKey('asset.id'), nullable=False, default=0) + user_id = Column(Integer, ForeignKey('upstage_user.id'), nullable=False, default=0) + approved = Column(Boolean, nullable=False, default=False) + seen = Column(Boolean, nullable=False, default=False) + note = Column(String, nullable=True) + created_on = Column(DateTime, nullable=False, default=datetime.utcnow) + user = relationship('User', foreign_keys=[user_id]) + asset = relationship('Asset', foreign_keys=[asset_id]) \ No newline at end of file diff --git a/licenses/sqlfiles/create.sql b/licenses/sqlfiles/create.sql index 6f952bcf..6cae57b4 100644 --- a/licenses/sqlfiles/create.sql +++ b/licenses/sqlfiles/create.sql @@ -3,8 +3,8 @@ DROP TABLE IF EXISTS "stage_license"; CREATE TABLE "public"."stage_license" ( "id" BIGSERIAL NOT NULL, "stage_id" integer NOT NULL, - "created_on" timestamp DEFAULT (now() at time zone 'utc'), - "expires_on" timestamp DEFAULT timezone('utc')) NOT NULL, + "created_on" timestamp without time zone DEFAULT (now() at time zone 'utc'), + "expires_on" timestamp without time zone NOT NULL, "access_path" character varying unique NOT NULL, PRIMARY KEY ("id"), FOREIGN KEY (stage_id) REFERENCES stage(id) ON UPDATE CASCADE ON DELETE CASCADE NOT DEFERRABLE @@ -25,3 +25,17 @@ CREATE TABLE "public"."asset_license" ( ); CREATE INDEX "asset_license_created_on" ON "public"."asset_license" USING btree ("created_on"); +-- ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +DROP TABLE IF EXISTS "asset_usage"; +CREATE TABLE "public"."asset_usage" ( + "id" BIGSERIAL NOT NULL, + "asset_id" integer NOT NULL, + "user_id" integer NOT NULL, + "approved" boolean NOT NULL DEFAULT false, + "seen" boolean NOT NULL DEFAULT false, + "note" TEXT, + "created_on" timestamp DEFAULT (now() at time zone 'utc'), + PRIMARY KEY ("id"), + FOREIGN KEY (asset_id) REFERENCES asset(id) ON UPDATE CASCADE ON DELETE CASCADE NOT DEFERRABLE, + FOREIGN KEY (user_id) REFERENCES upstage_user(id) ON UPDATE CASCADE ON DELETE CASCADE NOT DEFERRABLE +); diff --git a/performance_config/models.py b/performance_config/models.py index ba81cce6..30aa8ab4 100644 --- a/performance_config/models.py +++ b/performance_config/models.py @@ -37,8 +37,8 @@ class ParentStage(Base,db.Model): id = Column(BigInteger, primary_key=True) stage_id = Column(Integer, ForeignKey(Stage.id), nullable=False, default=0) child_asset_id = Column(Integer, ForeignKey(Asset.id), nullable=False, default=0) - stage = relationship(Stage, foreign_keys=[stage_id]) - child_asset = relationship(Asset, foreign_keys=[child_asset_id]) + stage = relationship(Stage, foreign_keys=[stage_id], back_populates="assets") + child_asset = relationship(Asset, foreign_keys=[child_asset_id], back_populates="stages") class ParentAsset(Base,db.Model): ''' diff --git a/requirements.pip b/requirements.pip index 20f464f0..1337ee71 100644 --- a/requirements.pip +++ b/requirements.pip @@ -11,6 +11,8 @@ flask-restx flask-sqlalchemy flask-talisman gevent +graphql-core==2.2.1 +graphene==2.1.8 graphene-sqlalchemy paho-mqtt pillow diff --git a/stage/asset.py b/stage/asset.py index 35024173..f9118429 100644 --- a/stage/asset.py +++ b/stage/asset.py @@ -14,7 +14,7 @@ from flask_jwt_extended.view_decorators import verify_jwt_in_request from graphene_sqlalchemy.fields import SQLAlchemyConnectionField from sqlalchemy.sql.expression import and_, or_ -from licenses.models import AssetLicense +from licenses.models import AssetLicense, AssetUsage import os from user.models import ADMIN, SUPER_ADMIN from user.user_utils import current_user @@ -362,6 +362,8 @@ def mutate(self, info, id): MediaTag.asset_id == id).delete(synchronize_session=False) local_db_session.query(AssetLicense).filter( AssetLicense.asset_id == id).delete(synchronize_session=False) + local_db_session.query(AssetUsage).filter( + AssetUsage.asset_id == id).delete(synchronize_session=False) for multiframe_media in local_db_session.query(AssetModel).filter(AssetModel.description.like(f"%{asset.file_location}%")).all(): attributes = json.loads(multiframe_media.description) diff --git a/stage/schema.py b/stage/schema.py index 70814c45..f7470bc8 100644 --- a/stage/schema.py +++ b/stage/schema.py @@ -80,10 +80,12 @@ class Stage(SQLAlchemyObjectType): class Meta: model = StageModel - model.db_id = model.id interfaces = (relay.Node,) connection_class = graphql_utils.CountableConnection + def resolve_db_id(self, info): + return self.id + def resolve_events(self, info, performance_id=None, cursor=0): events = DBSession.query(EventModel)\ .filter(EventModel.performance_id == performance_id)\ diff --git a/studio/media.py b/studio/media.py index 9109ec21..26a7c16a 100644 --- a/studio/media.py +++ b/studio/media.py @@ -1,5 +1,6 @@ import hashlib import json +from operator import itemgetter import os import sys import time @@ -11,14 +12,14 @@ from asset.models import Asset as AssetModel, MediaTag, Stage as StageModel, Tag from asset.models import AssetType as AssetTypeModel from user.models import MAKER, PLAYER, User as UserModel -from config.project_globals import ScopedSession, appdir +from config.project_globals import DBSession, ScopedSession, appdir from config.settings import STREAM_EXPIRY_DAYS, STREAM_KEY from flask_jwt_extended import get_jwt_identity, jwt_required from flask_jwt_extended.view_decorators import verify_jwt_in_request from graphene_sqlalchemy import SQLAlchemyObjectType from graphene_sqlalchemy.fields import SQLAlchemyConnectionField from graphql_relay.node.node import from_global_id -from licenses.models import AssetLicense +from licenses.models import AssetLicense, AssetUsage as AssetUsageModel from performance_config.models import ParentStage from sqlalchemy.orm import joinedload from sqlalchemy.sql.expression import and_, or_ @@ -43,26 +44,41 @@ class AssignedStage(graphene.ObjectType): url = graphene.String() +class AssetUsage(SQLAlchemyObjectType): + class Meta: + model = AssetUsageModel + interfaces = (graphene.relay.Node,) + + +class Previlege(graphene.Enum): + NONE = 0 + OWNER = 1 + APPROVED = 2 + PENDING_APPROVAL = 3 + REQUIRE_APPROVAL = 4 + + class Asset(SQLAlchemyObjectType): db_id = graphene.Int(description="Database ID") src = graphene.String(description="Logical path of the media") stages = graphene.List( AssignedStage, description="Stages that this media is assigned to") - copyright_level = graphene.Int(description="Copyright level") - playerAccess = graphene.String( - description="Users who can access and edit this media") - permission = graphene.String( - description="What permission the logged in user is granted to this media") + permissions = graphene.List( + AssetUsage, description="Users who had been granted or acknowledged to use this media") + privilege = Previlege( + description="Permission of the logged in user for this media") sign = graphene.String( description="Stream sign that is required to publish from broadcaster") tags = graphene.List(graphene.String, description="Media tags") class Meta: model = AssetModel - model.db_id = model.id interfaces = (graphene.relay.Node,) connection_class = CountableConnection + def resolve_db_id(self, info): + return self.id + def resolve_src(self, info): timestamp = int(time.mktime(self.updated_on.timetuple())) return self.file_location + '?t=' + str(timestamp) @@ -73,36 +89,30 @@ def resolve_stages(self, info): def resolve_tags(self, info): return [x.tag.name for x in self.tags.all()] - def resolve_copyright_level(self, info): - if self.asset_license: - return self.asset_license.level - return 0 - - def resolve_playerAccess(self, info): - if self.asset_license and self.asset_license.permissions: - return self.asset_license.permissions - return "[]" + def resolve_permissions(self, info): + return self.permissions.order_by(AssetUsageModel.created_on.desc()).all() - def resolve_permission(self, info): + def resolve_privilege(self, info): result = verify_jwt_in_request(True) user_id = get_jwt_identity() if not user_id: - return "none" + return Previlege.NONE if self.owner_id == user_id: - return 'owner' - if not self.asset_license or self.asset_license.level == 0: - return "editor" - if self.asset_license.level == 3: - return "none" - player_access = self.asset_license.permissions - if player_access: - accesses = json.loads(player_access) - if len(accesses) == 2: - if user_id in accesses[0]: - return "readonly" - elif user_id in accesses[1]: - return "editor" - return "none" + return Previlege.OWNER + if not self.copyright_level: # no copyright + return Previlege.APPROVED + if self.copyright_level == 3: + # not shared, will not visisble to anyone either, this condidtion is just in case + return Previlege.NONE + usage = DBSession.query(AssetUsageModel).filter( + AssetUsageModel.asset_id == self.id).filter(AssetUsageModel.user_id == user_id).first() + if usage: + if not usage.approved and self.copyright_level == 2: + return Previlege.PENDING_APPROVAL + else: + return Previlege.APPROVED + else: + return Previlege.REQUIRE_APPROVAL def resolve_sign(self, info): result = verify_jwt_in_request(True) @@ -122,9 +132,11 @@ class AssetType(SQLAlchemyObjectType): class Meta: model = AssetTypeModel - model.db_id = model.id interfaces = (graphene.relay.Node,) + def resolve_db_id(self, info): + return self.id + class AssetConnectionField(SQLAlchemyConnectionField): RELAY_ARGS = ['first', 'last', 'before', 'after'] @@ -137,10 +149,8 @@ def get_query(cls, model, info, sort=None, **args): result = verify_jwt_in_request(True) user_id = get_jwt_identity() query = query.filter(or_( - AssetModel.asset_license == None, - AssetModel.asset_license.has(AssetLicense.level < 3), - and_(AssetModel.asset_license.has(AssetLicense.level == 3), - AssetModel.owner_id == user_id) + AssetModel.copyright_level < 3, + AssetModel.copyright_level == 3 and AssetModel.owner_id == user_id )) for field, value in args.items(): if field == 'id': @@ -198,8 +208,6 @@ def mutate(self, info): local_db_session.flush() total += media.size - local_db_session.commit() - local_db_session.close() return CalcSizes(size=total) @@ -234,29 +242,38 @@ def mutate(self, info, base64, filename): return UploadFile(url=file_location) +class SaveStageInput(graphene.InputObjectType): + """Arguments to update a stage.""" + name = graphene.String( + required=True, description="Name of the media") + urls = graphene.List( + graphene.String, description="Uploaded url of files") + media_type = graphene.String( + description="Avatar/prop/backdrop,... default to just a generic media", default_value='media') + id = graphene.ID(description="ID of the media (for updating)") + copyright_level = graphene.Int(description="Copyright level") + user_ids = graphene.List( + graphene.Int, description="Users who can access and edit this media") + stage_ids = graphene.List( + graphene.Int, description="Id of stages to be assigned to") + tags = graphene.List( + graphene.String, description="Media tags") + w = graphene.Int(description="Width of the media") + h = graphene.Int(description="Height of the media") + + class SaveMedia(graphene.Mutation): """Mutation to upload a media.""" asset = graphene.Field( lambda: Asset, description="Media saved by this mutation.") class Arguments: - name = graphene.String( - required=True, description="Name of the media") - urls = graphene.List( - graphene.String, description="Uploaded url of files") - media_type = graphene.String( - description="Avatar/prop/backdrop,... default to just a generic media", default_value='media') - id = graphene.ID(description="ID of the media (for updating)") - copyright_level = graphene.Int(description="Copyright level") - player_access = graphene.String( - description="Users who can access and edit this media") - stage_ids = graphene.List( - graphene.Int, description="Id of stages to be assigned to") - tags = graphene.List( - graphene.String, description="Media tags") + input = SaveStageInput(required=True) @jwt_required() - def mutate(self, info, name, urls, media_type='media', id=None, copyright_level=None, player_access=None, stage_ids=None, tags=None): + def mutate(self, info, input): + name, urls, media_type, copyright_level, user_ids, stage_ids, tags, w, h = itemgetter( + 'name', 'urls', 'media_type', 'copyright_level', 'user_ids', 'stage_ids', 'tags', 'w', 'h')(input) current_user_id = get_jwt_identity() with ScopedSession() as local_db_session: asset_type = local_db_session.query(AssetTypeModel).filter( @@ -267,8 +284,8 @@ def mutate(self, info, name, urls, media_type='media', id=None, copyright_level= local_db_session.add(asset_type) local_db_session.flush() - if id: - id = from_global_id(id)[1] + if 'id' in input: + id = from_global_id(input['id'])[1] asset = local_db_session.query(AssetModel).filter( AssetModel.id == id).first() else: @@ -279,21 +296,8 @@ def mutate(self, info, name, urls, media_type='media', id=None, copyright_level= asset.name = name asset.asset_type = asset_type asset.file_location = urls[0] + asset.copyright_level = copyright_level asset.updated_on = datetime.utcnow() - - if asset.asset_license: - asset.asset_license.level = copyright_level - asset.asset_license.permissions = player_access - else: - if not asset.id: - local_db_session.flush() - - asset_license = AssetLicense( - asset_id=asset.id, - level=copyright_level, - permissions=player_access - ) - local_db_session.add(asset_license) local_db_session.flush() if urls: @@ -315,6 +319,11 @@ def mutate(self, info, name, urls, media_type='media', id=None, copyright_level= if len(urls) > 1: attributes['multi'] = True + else: + attributes['multi'] = False + attributes['frames'] = [] + attributes['w'] = w + attributes['h'] = h asset.description = json.dumps(attributes) local_db_session.flush() @@ -324,6 +333,22 @@ def mutate(self, info, name, urls, media_type='media', id=None, copyright_level= for id in stage_ids: asset.stages.append(ParentStage(stage_id=id)) + if user_ids != None: + granted_permissions = asset.permissions.all() + for permission in granted_permissions: + if isinstance(permission, AssetUsageModel): + if permission.user_id not in user_ids and permission.approved == True: + asset.permissions.remove(permission) + local_db_session.delete(permission) + for user_id in user_ids: + permission = local_db_session.query(AssetUsageModel).filter( + AssetUsageModel.asset_id == asset.id, AssetUsageModel.user_id == user_id).first() + if not permission: + permission = AssetUsageModel(user_id=user_id) + asset.permissions.append(permission) + permission.approved = True + local_db_session.flush() + if tags: asset.tags.delete() for tag in tags: @@ -340,3 +365,65 @@ def mutate(self, info, name, urls, media_type='media', id=None, copyright_level= asset = local_db_session.query(AssetModel).filter( AssetModel.id == asset.id).first() return SaveMedia(asset=asset) + + +class ConfirmPermission(graphene.Mutation): + """Mutation to approve or reject a media usage request""" + success = graphene.Boolean(description="Success") + message = graphene.String(description="Reason for why the mutation failed") + permissions = graphene.List( + lambda: AssetUsage, description="Permissions that were updated") + + class Arguments: + id = graphene.ID(description="ID of the media usage request") + approved = graphene.Boolean( + description="Whether the permission is approved. True for approving, False for rejecting") + + @jwt_required() + def mutate(self, info, id, approved): + id = from_global_id(id)[1] + with ScopedSession() as local_db_session: + asset_usage = local_db_session.query(AssetUsageModel).get(id) + asset_id = asset_usage.asset_id + if asset_usage: + code, error, user, timezone = current_user() + if not user.role in (ADMIN, SUPER_ADMIN): + if not user.id == asset_usage.asset.owner_id: + return ConfirmPermission(success=False, message="Only media owner or admin can delete this media!") + if approved: + asset_usage.approved = True + asset_usage.seen = True + else: + local_db_session.delete(asset_usage) + local_db_session.flush() + permissions = DBSession.query(AssetUsageModel).filter( + AssetUsageModel.asset_id == asset_id).all() + return ConfirmPermission(success=True, permissions=permissions) + + +class RequestPermission(graphene.Mutation): + """Mutation to create an asset usage""" + success = graphene.Boolean(description="Success") + + class Arguments: + asset_id = graphene.ID(description="ID of the media usage request") + note = graphene.String( + description="Note for the media usage request", required=False) + + @jwt_required() + def mutate(self, info, asset_id, note=None): + asset_id = from_global_id(asset_id)[1] + with ScopedSession() as local_db_session: + asset = local_db_session.query(AssetModel).get(asset_id) + if asset: + code, error, user, timezone = current_user() + asset_usage = AssetUsageModel( + user_id=user.id, asset_id=asset_id, note=note) + if asset.copyright_level == 2: + asset_usage.approved = False + else: + asset_usage.approved = True + local_db_session.add(asset_usage) + local_db_session.flush() + local_db_session.commit() + return ConfirmPermission(success=True) diff --git a/studio/notification.py b/studio/notification.py new file mode 100644 index 00000000..10bf63a3 --- /dev/null +++ b/studio/notification.py @@ -0,0 +1,35 @@ +from flask_jwt_extended.view_decorators import jwt_required +import graphene +from graphene_sqlalchemy.types import SQLAlchemyObjectType +import graphene +from config.project_globals import DBSession +from studio.media import AssetUsage +from user.user_utils import current_user +from licenses.models import AssetUsage as AssetUsageModel + + +class NotificationType(graphene.Enum): + MEDIA_USAGE = 1 + + +class Notification(graphene.ObjectType): + type = NotificationType(description="Type of notification") + mediaUsage = graphene.Field( + AssetUsage, description="If notification is of type media usage, this object contain the permission request") + + class Meta: + interfaces = (graphene.relay.Node,) + + def resolve_mediaUsage(self, info): + return self.mediaUsage + + +@jwt_required() +def resolve_notifications(self, info): + code, error, user, timezone = current_user() + notifications = [] + if user: + mediaUsages = [Notification(type=NotificationType.MEDIA_USAGE, mediaUsage=x) + for x in DBSession.query(AssetUsageModel).filter(AssetUsageModel.approved == False).filter(AssetUsageModel.asset.has(owner_id=user.id)).all()] + notifications += mediaUsages + return notifications diff --git a/studio/schema.py b/studio/schema.py index 327fb722..7a1f4e5f 100644 --- a/studio/schema.py +++ b/studio/schema.py @@ -10,12 +10,12 @@ from config.settings import VERSION from flask_graphql import GraphQLView from graphene import relay -from asset.models import Stage as StageModel -from asset.models import Tag as TagModel +from asset.models import Stage as StageModel, Tag as TagModel from stage.asset import DeleteMedia +from studio.notification import Notification, resolve_notifications from user.models import ROLES, User as UserModel, role_conv from studio.media import (Asset, AssetConnectionField, - AssetType, CalcSizes, SaveMedia, UploadFile) + AssetType, CalcSizes, ConfirmPermission, RequestPermission, SaveMedia, UploadFile) from user.user_utils import current_user appdir = os.path.abspath(os.path.dirname(__file__)) @@ -66,6 +66,7 @@ class Query(graphene.ObjectType): media = AssetConnectionField( Asset.connection, id=graphene.ID(), name_like=graphene.String(), created_between=graphene.List(graphene.Date), file_location=graphene.String(), media_types=graphene.List(graphene.String), owners=graphene.List(graphene.String), stages=graphene.List(graphene.Int), tags=graphene.List(graphene.String)) whoami = graphene.Field(User, description="Logged in user info") + notifications = graphene.List(Notification, resolver=resolve_notifications) @jwt_required() def resolve_whoami(self, info): @@ -79,6 +80,8 @@ class Mutation(graphene.ObjectType): uploadFile = UploadFile.Field() saveMedia = SaveMedia.Field() refreshUser = RefreshMutation.Field() + confirmPermission = ConfirmPermission.Field() + requestPermission = RequestPermission.Field() # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/system/dev/dev_app1_nginx_upstage.conf b/system/dev/dev_app1_nginx_upstage.conf index bcbe309c..9daf19ca 100644 --- a/system/dev/dev_app1_nginx_upstage.conf +++ b/system/dev/dev_app1_nginx_upstage.conf @@ -49,7 +49,7 @@ server { #limit_req zone=limitone burst=20 nodelay; #auth_basic "Enter password."; #auth_basic_user_file /etc/nginx/htpasswd; - alias /home/upstage/mobilise/ui/dashboard/dist/; + alias /home/upstage/upstage/ui/dashboard/dist/; try_files $uri $uri/ /index.html; add_header Last-Modified $date_gmt; add_header 'Cache-Control' 'no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0'; @@ -63,7 +63,7 @@ server { } location /studio { - alias /home/upstage/mobilise/ui/studio/dist/; + alias /home/upstage/upstage/ui/studio/dist/; try_files $uri $uri/ /V4.0/studio/index.html; add_header Last-Modified $date_gmt; add_header 'Cache-Control' 'no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0'; @@ -76,11 +76,15 @@ server { gzip_min_length 1000; } - location /V4.0/static { - #limit_req zone=limitone burst=20 nodelay; - #auth_basic "Enter password."; - #auth_basic_user_file /etc/nginx/htpasswd; - alias /home/upstage/mobilise/ui/static; + location /static { + alias /home/upstage/upstage/ui/static; + expires off; + add_header Cache-Control 'no-cache, must-revalidate'; + } + + location /V4.0/static { + # Keep this path for not breaking replays + alias /home/upstage/upstage/ui/static; expires off; add_header Cache-Control 'no-cache, must-revalidate'; } diff --git a/system/dev/event_archive.service b/system/dev/event_archive.service index 6e8c7fed..5e83384e 100644 --- a/system/dev/event_archive.service +++ b/system/dev/event_archive.service @@ -3,7 +3,7 @@ Description=UpStage event archive service [Service] Type=simple -ExecStart=/usr/bin/python3 /home/upstage/mobilise/run_event_archive.py +ExecStart=/usr/bin/python3 /home/upstage/upstage/run_event_archive.py [Install] WantedBy=multi-user.target \ No newline at end of file diff --git a/system/dev/upstage.ini b/system/dev/upstage.ini index fa5a3fac..ad62e779 100644 --- a/system/dev/upstage.ini +++ b/system/dev/upstage.ini @@ -17,9 +17,9 @@ enable-threads=false socket=/home/upstage/uwsgi_sockets/upstage.socket chown-socket=upstage:upstage chmod-socket=777 -chdir=/home/upstage/mobilise/ +chdir=/home/upstage/upstage/ module = run_upstage:app -env = PYTHONPATH=/home/upstage/mobilise +env = PYTHONPATH=/home/upstage/upstage # NO!!! daemonize=/var/log/uwsgi/urbn_stripe_webhook.log logto2 = /var/log/uwsgi/upstage.log diff --git a/system/dev/upstage.service b/system/dev/upstage.service index 9d83f407..ea58cc53 100644 --- a/system/dev/upstage.service +++ b/system/dev/upstage.service @@ -6,7 +6,7 @@ After=syslog.target LimitNOFILE=100000 User=upstage Group=upstage -ExecStart=/bin/sh -c "exec /usr/local/bin/uwsgi --ini /home/upstage/mobilise/system/dev/upstage.ini" +ExecStart=/bin/sh -c "exec /usr/local/bin/uwsgi --ini /home/upstage/upstage/system/dev/upstage.ini" # Requires systemd version 211 or newer RuntimeDirectory=uwsgi_socket Restart=always diff --git a/system/prod/app1_nginx_upstage.conf b/system/prod/app1_nginx_upstage.conf index 748e44f1..6142568d 100644 --- a/system/prod/app1_nginx_upstage.conf +++ b/system/prod/app1_nginx_upstage.conf @@ -47,7 +47,7 @@ server { #limit_req zone=limitone burst=20 nodelay; #auth_basic "Enter password."; #auth_basic_user_file /etc/nginx/htpasswd; - alias /home/upstage/mobilise/ui/dashboard/dist/; + alias /home/upstage/upstage/ui/dashboard/dist/; try_files $uri $uri/ /index.html; add_header Last-Modified $date_gmt; add_header 'Cache-Control' 'no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0'; @@ -62,7 +62,7 @@ server { location /studio { - alias /home/upstage/mobilise/ui/studio/dist/; + alias /home/upstage/upstage/ui/studio/dist/; try_files $uri $uri/ /studio/index.html; add_header Last-Modified $date_gmt; add_header 'Cache-Control' 'no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0'; @@ -75,11 +75,15 @@ server { gzip_min_length 1000; } + location /static { + alias /home/upstage/upstage/ui/static; + expires off; + add_header Cache-Control 'no-cache, must-revalidate'; + } + location /V4.0/static { - #limit_req zone=limitone burst=20 nodelay; - #auth_basic "Enter password."; - #auth_basic_user_file /etc/nginx/htpasswd; - alias /home/upstage/mobilise/ui/static; + # Keep this path for not breaking replays + alias /home/upstage/upstage/ui/static; expires off; add_header Cache-Control 'no-cache, must-revalidate'; } diff --git a/system/prod/event_archive.service b/system/prod/event_archive.service index 89456c68..15f5486c 100644 --- a/system/prod/event_archive.service +++ b/system/prod/event_archive.service @@ -3,7 +3,7 @@ Description=UpStage event archive service [Service] Type=simple -ExecStart=/usr/bin/python3 /home/upstage/mobilise/run_event_archive.py +ExecStart=/usr/bin/python3 /home/upstage/upstage/run_event_archive.py [Install] WantedBy=multi-user.target diff --git a/system/prod/upstage.ini b/system/prod/upstage.ini index fa5a3fac..ad62e779 100644 --- a/system/prod/upstage.ini +++ b/system/prod/upstage.ini @@ -17,9 +17,9 @@ enable-threads=false socket=/home/upstage/uwsgi_sockets/upstage.socket chown-socket=upstage:upstage chmod-socket=777 -chdir=/home/upstage/mobilise/ +chdir=/home/upstage/upstage/ module = run_upstage:app -env = PYTHONPATH=/home/upstage/mobilise +env = PYTHONPATH=/home/upstage/upstage # NO!!! daemonize=/var/log/uwsgi/urbn_stripe_webhook.log logto2 = /var/log/uwsgi/upstage.log diff --git a/system/prod/upstage.service b/system/prod/upstage.service index f48733d6..3d184d4d 100644 --- a/system/prod/upstage.service +++ b/system/prod/upstage.service @@ -6,7 +6,7 @@ After=syslog.target LimitNOFILE=100000 User=upstage Group=upstage -ExecStart=/bin/sh -c "exec /usr/local/bin/uwsgi --ini /home/upstage/mobilise/system/prod/upstage.ini" +ExecStart=/bin/sh -c "exec /usr/local/bin/uwsgi --ini /home/upstage/upstage/system/prod/upstage.ini" # Requires systemd version 211 or newer RuntimeDirectory=uwsgi_socket Restart=always diff --git a/ui/dashboard/.env b/ui/dashboard/.env deleted file mode 100644 index 655007dd..00000000 --- a/ui/dashboard/.env +++ /dev/null @@ -1,5 +0,0 @@ -VUE_APP_API_ENDPOINT=https://upstage.live/V4.0/ -VUE_APP_GRAPHQL_ENDPOINT=https://upstage.live/V4.0/ -VUE_APP_STATIC_ASSETS_ENDPOINT=https://upstage.live/V4.0/static/assets/ -VUE_APP_STUDIO_ENDPOINT=https://upstage.live/studio/ -VUE_APP_MQTT_NAMESPACE=app1 diff --git a/ui/dashboard/.env.dev b/ui/dashboard/.env.dev index 2026de0e..247391e0 100644 --- a/ui/dashboard/.env.dev +++ b/ui/dashboard/.env.dev @@ -1,5 +1,12 @@ VUE_APP_API_ENDPOINT=https://dev-app1.upstage.org.nz/V4.0/ VUE_APP_GRAPHQL_ENDPOINT=https://dev-app1.upstage.org.nz/V4.0/ -VUE_APP_STATIC_ASSETS_ENDPOINT=https://dev-app1.upstage.org.nz/V4.0/static/assets/ +VUE_APP_STATIC_ASSETS_ENDPOINT=https://dev-app1.upstage.org.nz/static/assets/ VUE_APP_STUDIO_ENDPOINT=https://dev-app1.upstage.org.nz/studio/ -VUE_APP_MQTT_NAMESPACE=dev \ No newline at end of file +VUE_APP_MQTT_NAMESPACE=dev +VUE_APP_MQTT_ENDPOINT=wss://svc1.upstage.org.nz:9002/mqtt +VUE_APP_MQTT_USERNAME=performance +VUE_APP_MQTT_PASSWORD=z48FCTsJVEUkYmtUw5S9 +VUE_APP_STREAMING_PUBLISH_ENDPOINT=rtmp://streaming1.upstage.org.nz:1941/live +VUE_APP_STREAMING_SUBSCRIBE_ENDPOINT=https://streaming1.upstage.org.nz:9999/ +VUE_APP_STREAMING_USERNAME=admin +VUE_APP_STREAMING_PASSWORD=admin \ No newline at end of file diff --git a/ui/dashboard/.env.live b/ui/dashboard/.env.live new file mode 100644 index 00000000..e4855dc9 --- /dev/null +++ b/ui/dashboard/.env.live @@ -0,0 +1,12 @@ +VUE_APP_API_ENDPOINT=https://upstage.live/V4.0/ +VUE_APP_GRAPHQL_ENDPOINT=https://upstage.live/V4.0/ +VUE_APP_STATIC_ASSETS_ENDPOINT=https://upstage.live/static/assets/ +VUE_APP_STUDIO_ENDPOINT=https://upstage.live/studio/ +VUE_APP_MQTT_NAMESPACE=app1 +VUE_APP_MQTT_ENDPOINT=wss://svc1.upstage.org.nz:9002/mqtt +VUE_APP_MQTT_USERNAME=performance +VUE_APP_MQTT_PASSWORD=z48FCTsJVEUkYmtUw5S9 +VUE_APP_STREAMING_PUBLISH_ENDPOINT=rtmp://streaming1.upstage.org.nz:1941/live +VUE_APP_STREAMING_SUBSCRIBE_ENDPOINT=https://streaming1.upstage.org.nz:9999/ +VUE_APP_STREAMING_USERNAME=admin +VUE_APP_STREAMING_PASSWORD=admin \ No newline at end of file diff --git a/ui/dashboard/.env.localhost b/ui/dashboard/.env.localhost index 3a707dc4..03e59e43 100644 --- a/ui/dashboard/.env.localhost +++ b/ui/dashboard/.env.localhost @@ -1,4 +1,4 @@ VUE_APP_API_ENDPOINT=http://localhost:5000/V4.0/ VUE_APP_GRAPHQL_ENDPOINT=http://localhost:5000/V4.0/ -VUE_APP_STATIC_ASSETS_ENDPOINT=https://app1.upstage.org.nz/V4.0/static/assets/ +VUE_APP_STATIC_ASSETS_ENDPOINT=https://app1.upstage.org.nz/static/assets/ VUE_APP_MQTT_NAMESPACE=dev \ No newline at end of file diff --git a/ui/dashboard/.env.meta b/ui/dashboard/.env.meta new file mode 100644 index 00000000..764e7048 --- /dev/null +++ b/ui/dashboard/.env.meta @@ -0,0 +1,12 @@ +VUE_APP_API_ENDPOINT=https://meta.upstage.live/V4.0/ +VUE_APP_GRAPHQL_ENDPOINT=https://meta.upstage.live/V4.0/ +VUE_APP_STATIC_ASSETS_ENDPOINT=https://meta.upstage.live/static/assets/ +VUE_APP_STUDIO_ENDPOINT=https://meta.upstage.live/studio/ +VUE_APP_MQTT_NAMESPACE=meta +VUE_APP_MQTT_ENDPOINT=wss://svc1.meta.upstage.live:9002/mqtt +VUE_APP_MQTT_USERNAME=meta +VUE_APP_MQTT_PASSWORD=7#Yr4egD'X&u]{wyK +VUE_APP_STREAMING_PUBLISH_ENDPOINT=rtmp://streaming1.meta.upstage.live:1941/live +VUE_APP_STREAMING_SUBSCRIBE_ENDPOINT=https://streaming1.meta.upstage.live:9999/ +VUE_APP_STREAMING_USERNAME=admin +VUE_APP_STREAMING_PASSWORD=admin \ No newline at end of file diff --git a/ui/dashboard/.gitignore b/ui/dashboard/.gitignore index 403adbc1..0035d70c 100644 --- a/ui/dashboard/.gitignore +++ b/ui/dashboard/.gitignore @@ -4,6 +4,7 @@ node_modules # local env files +.env .env.local .env.*.local diff --git a/ui/dashboard/package.json b/ui/dashboard/package.json index df0b914f..7c7be347 100644 --- a/ui/dashboard/package.json +++ b/ui/dashboard/package.json @@ -1,7 +1,7 @@ { "name": "dashboard", "version": "4.7.0", - "versionAlias": "MB-13", + "versionAlias": "TC-14", "private": true, "scripts": { "serve": "vue-cli-service serve --mode dev", diff --git a/ui/dashboard/src/components/NavBar.vue b/ui/dashboard/src/components/NavBar.vue index 70e61fa1..ede94624 100644 --- a/ui/dashboard/src/components/NavBar.vue +++ b/ui/dashboard/src/components/NavBar.vue @@ -17,63 +17,40 @@