From 4a180e3784091311fe90a715830758e55dcaff82 Mon Sep 17 00:00:00 2001 From: Francesco Siddi Date: Wed, 3 Apr 2019 15:54:37 +0200 Subject: [PATCH] Introducing setup_for_film functionality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It is now possible, only for user with admin capability, to setup a project as ‘film’. This action can be performed via CLI using ./manage.py cloud setup_for_film or via the web interface in the Cloud settings area. Setting up a project for film creates a number of extension props under the ‘cloud’ key. Such properties are listed in the cloud_extension_props variable in setup.py. At this moment the functionality exists for a very specific purpose: improving the presentation of public Film projects in the Blender Cloud. It can be further extended to improve the presentation of Training and Libraries later on. --- cloud/__init__.py | 46 ++++++++ cloud/cli.py | 10 ++ cloud/forms.py | 16 +++ cloud/routes.py | 105 +++++++++++++++++- cloud/setup.py | 55 +++++++++ .../project_settings/cloud_layout.pug | 21 ++++ .../project_settings/offer_setup.pug | 28 +++++ src/templates/project_settings/settings.pug | 67 +++++++++++ 8 files changed, 346 insertions(+), 2 deletions(-) create mode 100644 cloud/forms.py create mode 100644 cloud/setup.py create mode 100644 src/templates/project_settings/cloud_layout.pug create mode 100644 src/templates/project_settings/offer_setup.pug create mode 100644 src/templates/project_settings/settings.pug diff --git a/cloud/__init__.py b/cloud/__init__.py index c741ea6..a3bcadb 100644 --- a/cloud/__init__.py +++ b/cloud/__init__.py @@ -3,6 +3,8 @@ import logging import flask from werkzeug.local import LocalProxy +import pillarsdk +import pillar.auth from pillar.api.utils import authorization from pillar.extension import PillarExtension @@ -87,6 +89,50 @@ class CloudExtension(PillarExtension): 'current_user_is_subscriber': authorization.user_has_cap('subscriber') } + def is_cloud_project(self, project): + """Returns whether the project is set up for Blender Cloud. + + Requires the presence of the 'cloud' key in extension_props + """ + + try: + pprops = project.extension_props[EXTENSION_NAME] + except AttributeError: + self._log.warning("is_cloud_project: Project url=%r doesn't have any " + "extension properties.", project['url']) + if self._log.isEnabledFor(logging.DEBUG): + import pprint + self._log.debug('Project: %s', pprint.pformat(project.to_dict())) + return False + except KeyError: + # Not set up for Blender Cloud + return False + + if pprops is None: + self._log.debug("is_cloud_project: Project url=%r doesn't have Blender Cloud" + " extension properties.", project['url']) + return False + return True + + @property + def has_project_settings(self) -> bool: + # Only available for admins + return pillar.auth.current_user.has_cap('admin') + + def project_settings(self, project: pillarsdk.Project, **template_args: dict) -> flask.Response: + """Renders the project settings page for this extension. + + Set YourExtension.has_project_settings = True and Pillar will call this function. + + :param project: the project for which to render the settings. + :param template_args: additional template arguments. + :returns: a Flask HTTP response + """ + + from cloud.routes import project_settings + + return project_settings(project, **template_args) + def setup_app(self, app): from . import routes, webhooks, eve_hooks, email diff --git a/cloud/cli.py b/cloud/cli.py index 076842f..52efcd9 100644 --- a/cloud/cli.py +++ b/cloud/cli.py @@ -9,6 +9,8 @@ import requests from pillar.cli import manager from pillar.api import service +from pillar.api.utils import authentication +import cloud.setup log = logging.getLogger(__name__) @@ -126,4 +128,12 @@ def reconcile_subscribers(): log.info(' skipped : %d', count_skipped) +@manager_cloud.command +def setup_for_film(project_url): + """Adds Blender Cloud film custom properties to a project.""" + + authentication.force_cli_user() + cloud.setup.setup_for_film(project_url) + + manager.add_command("cloud", manager_cloud) diff --git a/cloud/forms.py b/cloud/forms.py new file mode 100644 index 0000000..a353bdf --- /dev/null +++ b/cloud/forms.py @@ -0,0 +1,16 @@ +from flask_wtf import FlaskForm +from wtforms import BooleanField, StringField +from wtforms.validators import URL +from flask_wtf.html5 import URLField + +from pillar.web.utils.forms import FileSelectField + + +class FilmProjectForm(FlaskForm): + video_url = URLField(validators=[URL()]) + picture_16_9 = FileSelectField('Picture 16x9', file_format='image') + poster = FileSelectField('Poster Image', file_format='image') + logo = FileSelectField('Logo', file_format='image') + is_in_production = BooleanField('In Production') + is_featured = BooleanField('Featured') + theme_color = StringField('Theme Color') diff --git a/cloud/routes.py b/cloud/routes.py index c0ddc36..3e14fe0 100644 --- a/cloud/routes.py +++ b/cloud/routes.py @@ -3,14 +3,19 @@ import json import logging import typing -from flask_login import current_user, login_required +import bson +from flask_login import login_required import flask +import werkzeug.exceptions as wz_exceptions from flask import Blueprint, render_template, redirect, session, url_for, abort, flash from pillarsdk import Node, Project, User, exceptions as sdk_exceptions, Group from pillarsdk.exceptions import ResourceNotFound +import pillar +import pillarsdk from pillar import current_app -import pillar.api +from pillar.api.utils import authorization +from pillar.auth import current_user from pillar.web.users import forms from pillar.web.utils import system_util, get_file, current_user_is_authenticated from pillar.web.utils import attach_project_pictures @@ -18,7 +23,11 @@ from pillar.web.settings import blueprint as blueprint_settings from pillar.web.nodes.routes import url_for_node from pillar.web.projects.routes import render_project from pillar.web.projects.routes import find_project_or_404 +from pillar.web.projects.routes import project_view +from cloud import current_cloud +from cloud.forms import FilmProjectForm +from . import EXTENSION_NAME blueprint = Blueprint('cloud', __name__) log = logging.getLogger(__name__) @@ -412,6 +421,14 @@ def project_hero(): header_video_file = get_file(project.header_node.properties.file) header_video_node.picture = get_file(header_video_node.picture) + # Load custom project properties + if EXTENSION_NAME in project.extension_props: + extension_props = project['extension_props'][EXTENSION_NAME] + file_props = {'picture_16_9', 'logo'} + for f in file_props: + if f in extension_props: + extension_props[f] = get_file(extension_props[f]) + pages = Node.all({ 'where': {'project': project._id, 'node_type': 'page'}, 'projection': {'name': 1}}, api=api) @@ -423,6 +440,90 @@ def project_hero(): template_name='projects/landing.html') +def project_settings(project: pillarsdk.Project, **template_args: dict): + """Renders the project settings page for Blender Cloud projects. + + If the project has been setup for Blender Cloud, check for the cloud.project_type + property, to render the proper form. + """ + + # Based on the project state, we can render a different template. + if not current_cloud.is_cloud_project(project): + return render_template('project_settings/offer_setup.html', + project=project, **template_args) + + cloud_props = project['extension_props'][EXTENSION_NAME] + + project_type = cloud_props['project_type'] + if project_type != 'film': + log.error('No interface available to edit %s projects, yet' % project_type) + + form = FilmProjectForm() + + # Iterate over the form fields and set the data if exists in the project document + for field_name in form.data: + if field_name not in cloud_props: + continue + # Skip csrf_token field + if field_name == 'csrf_token': + continue + form_field = getattr(form, field_name) + form_field.data = cloud_props[field_name] + + return render_template('project_settings/settings.html', + project=project, + form=form, + **template_args) + + +@blueprint.route('//settings/film', methods=['POST']) +@authorization.require_login(require_cap='admin') +@project_view() +def save_film_settings(project: pillarsdk.Project): + # Ensure that the project is setup for Cloud (see @attract_project_view for example) + form = FilmProjectForm() + if not form.validate_on_submit(): + log.debug('Form submission failed') + # Return list of validation errors + + updated_extension_props = {} + for field_name in form.data: + # Skip csrf_token field + if field_name == 'csrf_token': + continue + form_field = getattr(form, field_name) + # TODO(fsiddi) if form_field type is FileSelectField, convert it to ObjectId + # Currently this raises TypeError: Object of type 'ObjectId' is not JSON serializable + updated_extension_props[field_name] = form_field.data + + # Update extension props and save project + extension_props = project['extension_props'][EXTENSION_NAME] + # Project is a Resource, so we update properties iteratively + for k, v in updated_extension_props.items(): + extension_props[k] = v + project.update(api=system_util.pillar_api()) + return '', 204 + + +@blueprint.route('//setup-for-film', methods=['POST']) +@login_required +@project_view() +def setup_for_film(project: pillarsdk.Project): + import cloud.setup + + project_id = project._id + + if not project.has_method('PUT'): + log.warning('User %s tries to set up project %s for Blender Cloud, but has no PUT rights.', + current_user, project_id) + raise wz_exceptions.Forbidden() + + log.info('User %s sets up project %s for Blender Cloud', current_user, project_id) + cloud.setup.setup_for_film(project.url) + + return '', 204 + + def setup_app(app): global _homepage_context cached = app.cache.cached(timeout=300) diff --git a/cloud/setup.py b/cloud/setup.py new file mode 100644 index 0000000..5000199 --- /dev/null +++ b/cloud/setup.py @@ -0,0 +1,55 @@ +"""Setting up projects for Blender Cloud.""" + +import logging + +from bson import ObjectId +from eve.methods.put import put_internal +from flask import current_app + +from pillar.api.utils import remove_private_keys +from . import EXTENSION_NAME + +log = logging.getLogger(__name__) + + +def setup_for_film(project_url): + """Add Blender Cloud extension_props specific for film projects. + + Returns the updated project. + """ + + projects_collection = current_app.data.driver.db['projects'] + + # Find the project in the database. + project = projects_collection.find_one({'url': project_url}) + if not project: + raise RuntimeError('Project %s does not exist.' % project_url) + + # Set default extension properties. Be careful not to overwrite any properties that + # are already there. + all_extension_props = project.setdefault('extension_props', {}) + cloud_extension_props = { + 'project_type': 'film', + 'theme_css': '', + # The accent color (can be 'blue' or '#FFBBAA' or 'rgba(1, 1, 1, 1) + 'theme_color': '', + 'is_in_production': False, + 'video_url': '', # Oembeddable url + 'poster': None, # File ObjectId + 'logo': None, # File ObjectId + # TODO(fsiddi) when we introduce other setup_for_* in Blender Cloud, make available + # at a higher scope + 'picture_16_9': None, + 'is_featured': False, + } + + all_extension_props.setdefault(EXTENSION_NAME, cloud_extension_props) + + project_id = ObjectId(project['_id']) + project = remove_private_keys(project) + result, _, _, status_code = put_internal('projects', project, _id=project_id) + + if status_code != 200: + raise RuntimeError("Can't update project %s, issues: %s", project_id, result) + + log.info('Project %s was updated for Blender Cloud.', project_url) diff --git a/src/templates/project_settings/cloud_layout.pug b/src/templates/project_settings/cloud_layout.pug new file mode 100644 index 0000000..592c9ba --- /dev/null +++ b/src/templates/project_settings/cloud_layout.pug @@ -0,0 +1,21 @@ +| {% extends 'projects/edit_layout.html' %} +| {% set title = 'cloud' %} +| {% block page_title %}Blender Cloud settings for {{ project.name }}{% endblock %} + +| {% block head %} +script(src="{{ url_for('static_attract', filename='assets/js/generated/tutti.min.js') }}") +| {% endblock %} + +| {% block project_context %} +.container-fluid + .row + .col-md-12 + h5.pt-3 {{ self.page_title() }} + + hr + +#node-edit-container + | {% block cloud_container %} + | {% endblock cloud_container %} + +| {% endblock project_context %} diff --git a/src/templates/project_settings/offer_setup.pug b/src/templates/project_settings/offer_setup.pug new file mode 100644 index 0000000..495e6c2 --- /dev/null +++ b/src/templates/project_settings/offer_setup.pug @@ -0,0 +1,28 @@ +| {% extends 'project_settings/cloud_layout.html' %} + +| {% block cloud_container %} +#node-edit-form + p This project is not setup for Blender Cloud #[span.text-muted (yet!)] + p + button.btn.btn-outline-primary.px-3(onclick='setupForFilm()') + i.pr-2.pi-blender-cloud + | Setup Project for Film + +| {% endblock cloud_container %} + +| {% block footer_scripts %} +script. + function setupForFilm() { + $.ajax({ + url: '{{ url_for( "cloud.setup_for_film", project_url=project.url) }}', + method: 'POST', + }) + .done(function() { + window.location.reload(); + }) + .fail(function(err) { + var err_elt = xhrErrorResponseElement(err, 'Error setting up your project: '); + toastr.error(err_elt); + }); + } +| {% endblock %} diff --git a/src/templates/project_settings/settings.pug b/src/templates/project_settings/settings.pug new file mode 100644 index 0000000..5d4a688 --- /dev/null +++ b/src/templates/project_settings/settings.pug @@ -0,0 +1,67 @@ +| {% extends 'project_settings/cloud_layout.html' %} + +| {% block cloud_container %} +#node-edit-form + form(onsubmit="save(this, '{{ url_for('cloud.save_film_settings', project_url=project['url']) }}'); return false;") + | {% for field in form %} + + | {% if field.name == 'csrf_token' %} + | {{ field }} + | {% else %} + | {% if field.type == 'HiddenField' %} + | {{ field }} + | {% else %} + + | {% if field.name not in hidden_fields %} + + .form-group(class="{{field.name}}{% if field.errors %} error{% endif %}") + | {{ field.label }} + | {% if field.name == 'picture' %} + | {% if post.picture %} + img.node-preview-thumbnail(src="{{ post.picture.thumbnail('m', api=api) }}") + a(href="#", class="file_delete", data-field-name="picture", data-file_id="{{post.picture._id}}") Delete + | {% endif %} + | {% endif %} + | {{ field(class='form-control') }} + + | {% if field.errors %} + ul.error + | {% for error in field.errors %} + li {{ error }} + | {% endfor %} + | {% endif %} + + | {% else %} + | {{ field(class='d-none') }} + | {% endif %} + + | {% endif %} + | {% endif %} + + | {% endfor %} + button.btn.btn-outline-success.btn-block(type='submit') + i.pi-check + | Save + + +| {% endblock cloud_container %} +| {% block footer_scripts %} +script(type='text/javascript', src="{{ url_for('static_pillar', filename='assets/js/vendor/jquery.ui.widget.min.js') }}") +script(type='text/javascript', src="{{ url_for('static_pillar', filename='assets/js/vendor/jquery.iframe-transport.min.js') }}") +script(type='text/javascript', src="{{ url_for('static_pillar', filename='assets/js/vendor/jquery.fileupload.min.js') }}") +script(type='text/javascript', src="{{ url_for('static_pillar', filename='assets/js/file_upload.min.js') }}") + +script. + ProjectUtils.setProjectAttributes({projectId: "{{project._id}}", isProject: true, nodeId: ''}); + + function save(form, url) { + let serizalizedData = $(form).serializeArray() + $.post(url, serizalizedData) + .done(function(xhr) { + toastr.success('Properties saved'); + }) + .fail(function(err) { + toastr.error(xhrErrorResponseElement(err, 'Error saving properties: ')); + }); + } +| {% endblock %}