WIP: Attach invoice PDF to payment emails #104418

Draft
Anna Sirota wants to merge 4 commits from attach-invoice-pdf into main

When changing the target branch, be careful to rebase the branch in your fork to match. See documentation.
104 changed files with 1051 additions and 1571 deletions
Showing only changes of commit 884a565f33 - Show all commits

3
.gitmodules vendored
View File

@ -2,3 +2,6 @@
path = assets_shared
url = https://projects.blender.org/infrastructure/web-assets.git
branch = v2
[submodule "playbooks/shared"]
path = playbooks/shared
url = https://projects.blender.org/infrastructure/web-playbooks

@ -1 +1 @@
Subproject commit 448320696057165a83cc91eee8854f561a952ff4
Subproject commit c47e6b36b73b393337caac122f2fa9fa363580bb

View File

@ -12,9 +12,10 @@ from comments.models import Comment
from comments.queries import get_annotated_comments
from comments.views.common import comments_to_template_type
import common.queries
from common.mixins import PaginatedViewMixin
class PostList(ListView):
class PostList(PaginatedViewMixin):
model = Post
context_object_name = 'posts'
paginate_by = 12

View File

@ -8,6 +8,7 @@ from django.contrib import admin
from django.db import models
from django.db.models.base import Model
from django.http.request import HttpRequest
from django.views.generic import ListView
from django.utils.safestring import mark_safe
import looper.model_mixins
@ -171,3 +172,21 @@ class SetModifiedByViewMixin:
obj = super().get_object(*args, **kwargs)
obj._modified_by_user_id = self.request.user.pk
return obj
class PaginatedViewMixin(ListView):
"""A custom Paginator that shows 3 clickable items (plus prev and next), instead
of the default 2.
"""
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
paginator = context['paginator']
page_obj = context['page_obj']
current_page = page_obj.number
# Determine the range of pages to show
start_page = max(current_page - 2, 1)
end_page = min(current_page + 2, paginator.num_pages)
context['page_range'] = range(start_page, end_page + 1)
return context

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

View File

@ -1,50 +1,59 @@
// TODO: make script more generic if multiple subnavs are added
document.addEventListener('DOMContentLoaded', function() {
const navGlobalLinkTraining = document.querySelector('.js-nav-global-link-training');
const navSubnavTraining = document.querySelector('.js-nav-subnav-training');
const navGlobalSubnavLinks = document.querySelectorAll('.js-nav-global-subnav-link');
function positionNavSubnavTraining() {
// Get 'navGlobalLinkTraining' position left
const navGlobalLinkTrainingRect = navGlobalLinkTraining.getBoundingClientRect();
const navGlobalLinkTrainingPositionLeft = navGlobalLinkTrainingRect.left;
// Position 'navSubnavTraining'
navSubnavTraining.style.left = navGlobalLinkTrainingPositionLeft + 'px';
if (!navGlobalSubnavLinks || navGlobalSubnavLinks.length === 0) {
return;
}
function hideNavSubnavTraining() {
navSubnavTraining.classList.remove('show');
function showNavSubnavPopover(navSubnavPopover, link) {
positionNavSubnavPopover(navSubnavPopover, link);
navSubnavPopover.classList.add('show');
}
function showNavSubnavTraining() {
navSubnavTraining.classList.add('show');
function hideNavSubnavPopover(navSubnavPopover) {
navSubnavPopover.classList.remove('show');
}
function positionNavSubnavPopover(navSubnavPopover, link) {
// Get the link's left position
const navGlobalSubnavLinkRect = link.getBoundingClientRect();
const navGlobalSubnavLinkPositionLeft = navGlobalSubnavLinkRect.left;
// Position 'navSubnavPopover'
navSubnavPopover.style.left = navGlobalSubnavLinkPositionLeft + 'px';
}
function init() {
positionNavSubnavTraining();
navGlobalSubnavLinks.forEach(link => {
const navGlobalSubnavLinkAttr = link.getAttribute('data-subnav');
const navSubnavPopover = document.querySelector(navGlobalSubnavLinkAttr);
// Create event 'navGlobalLinkTraining' on mouseover
navGlobalLinkTraining.addEventListener('mouseover', function() {
showNavSubnavTraining();
});
// Create event 'navGlobalLinkTraining' on mouseleave
navGlobalLinkTraining.addEventListener('mouseleave', function(e) {
// Check if mouse has left 'navSubnavTraining' area
if (!navSubnavTraining.contains(e.relatedTarget)) {
hideNavSubnavTraining();
if (!navSubnavPopover) {
return;
}
});
// Create event 'navSubnavTraining' on mouseleave
navSubnavTraining.addEventListener('mouseleave', function() {
hideNavSubnavTraining();
});
// Create event 'navGlobalSubnavLink' on mouseover
link.addEventListener('mouseover', function() {
showNavSubnavPopover(navSubnavPopover, link);
});
// Reposition 'navSubnavTraining' on window resize
window.addEventListener('resize', function() {
positionNavSubnavTraining();
// Create event 'navGlobalSubnavLink' on mouseleave
link.addEventListener('mouseleave', function(e) {
// Check if mouse has left 'navSubnavPopover' area
if (!navSubnavPopover.contains(e.relatedTarget)) {
hideNavSubnavPopover(navSubnavPopover);
}
});
// Create event 'navSubnavPopover' on mouseleave
navSubnavPopover.addEventListener('mouseleave', function() {
hideNavSubnavPopover(navSubnavPopover);
});
// Reposition 'navSubnavPopover' on window resize
window.addEventListener('resize', function() {
hideNavSubnavPopover(navSubnavPopover);
});
});
}

View File

@ -3,18 +3,17 @@ function markAsRead(event) {
event.preventDefault();
const element = event.currentTarget;
const url = element.dataset.markReadUrl;
if (element.dataset.isRead === 'true') return;
ajax.jsonRequest('POST', url).then(() => {
if (element.href) {
window.location.href = element.href;
} else {
element
.closest('.activity-list-item-wrapper')
.querySelectorAll('.unread')
.forEach((i) => {
i.classList.remove('unread');
});
// TODO: @web-assets optionally make js notifications mark as read named function part of web-assets, that can be called on project level
element.closest('.js-notifications-item')
// Set notifications item parent is read
.classList.add('is-read');
const tooltip = bootstrap.Tooltip.getInstance(event.target);
tooltip.dispose();
@ -29,15 +28,9 @@ function markAllAsRead(event) {
const url = element.dataset.markAllReadUrl;
ajax.jsonRequest('POST', url).then(() => {
document.querySelectorAll('.unread').forEach((i) => {
i.classList.remove('unread');
if (
i.closest('.activity-list-item-wrapper') &&
i.closest('.activity-list-item-wrapper').querySelector('.markasread')
) {
i.closest('.activity-list-item-wrapper').querySelector('.markasread').remove();
}
document.querySelectorAll('.js-notifications-item').forEach((i) => {
// Set notifications items is read
i.classList.add('is-read');
if (document.querySelector('.notifications-counter')) {
document.querySelector('.notifications-counter').remove();

View File

@ -369,20 +369,23 @@ function toggleNavDrawer() {
const navDrawerBtnToggle = document.querySelector('.js-nav-drawer-btn-toggle');
const navDrawerHelper = document.querySelector('.js-nav-drawer-helper');
navDrawerBtnToggle.addEventListener('click', function() {
// Check if navDrawerBtnToggle is active
if (this.classList.contains('active')) {
// Show 'navDrawerHelper'
this.classList.remove('active');
// Check if 'navDrawerBtnToggle' exists
if (navDrawerBtnToggle) {
navDrawerBtnToggle.addEventListener('click', function() {
// Check if navDrawerBtnToggle is active
if (this.classList.contains('active')) {
// Show 'navDrawerHelper'
this.classList.remove('active');
navDrawerHelper.classList.remove('show');
} else {
// Hide 'navDrawerHelper'
this.classList.add('active');
navDrawerHelper.classList.remove('show');
} else {
// Hide 'navDrawerHelper'
this.classList.add('active');
navDrawerHelper.classList.add('show');
}
});
navDrawerHelper.classList.add('show');
}
});
}
}
// Create function init

View File

@ -1,7 +1,7 @@
/* Breadcrumb. */
.breadcrumb-item
&::before
padding-top: var(--spacer-1)
&::before
padding-top: var(--spacer-1)
/* Button group. */
.button-toolbar

View File

@ -203,7 +203,33 @@ pre
/* Dropdown */
.dropdown-menu
&.dropdown-menu-notification
max-width: 72.0rem
max-width: 64.0rem
top: 1.2rem !important // Optically aligned vertically with subnavs
width: 100%
// TODO: @web-assets optionally create dropdown or floating notifications box in web-assets
.notifications-item
color: var(--box-text-color)
line-height: 1.5
&.is-read
color: var(--color-text-secondary)
.notifications-item-time
.date
font-family: var(--font-family-mono)
+media-md
font-size: 1.4rem
.notifications-list
background-color: transparent
box-shadow: none
font-size: var(--fs-sm)
+padding(0, x)
&.theme-dark
background-color: transparent
&.dropdown-menu-other-contributors
.dropdown-item
@ -480,6 +506,13 @@ input
&:focus
color: var(--color-text-primary) !important
.notifications-list-activity
.notifications-item-dot
display: none
.notifications-item-nav
display: none
/* Payment. */
.braintree-heading
color: var(--color-text)
@ -622,6 +655,93 @@ button,
filter: blur(var(--filter-blur-value))
transform: scale(1.1)
.training-group
--training-group-item-content-width: 100%
--training-group-item-nav-width: 100%
+media-xl
--training-group-item-content-width: 40.0rem
--training-group-item-nav-width: 30.0rem
+media-xxl
--training-group-item-content-width: 54.0rem
--training-group-item-nav-width: 40.0rem
.training-group-item
+padding(3, x)
&:last-child
+media-xl
padding-right: 0
.training-group-item-content
width: var(--training-group-item-content-width)
.box
background-color: var(--color-bg-tertiary)
+padding(3)
.comment-input-div
&.form-control
background-color: var(--color-bg-primary)
.replies
.comment
background-color: var(--color-bg-secondary)
.top-level-comment
background-color: var(--color-bg-primary)
.training-group-item-content-detail
width: 100%
.cards-item-title
font-size: var(--fs-h4)
line-height: var(--lh-base)
+media-lg
.training-group-item-content-detail-inner
max-width: 114.0rem
+media-xl
width: calc(100% - var(--training-group-item-nav-width))
.cards
--cards-items-per-row: 4
+media-xxl
.cards
--cards-items-per-row: 5
// TODO: revise training-group-item-nav display toggle on medium and small screens
.training-group-item-nav
+margin(3, bottom)
width: var(--training-group-item-nav-width)
.training-group-item-video
background-color: black
+margin(3, bottom)
padding: 0
width: 100%
+media-xl
align-items: center
display: flex
height: 100vh
justify-content: center
left: 0
margin-bottom: 0
position: sticky
top: 0
width: calc(100% - var(--training-group-item-content-width) - var(--training-group-item-nav-width))
.training-header-img-helper
align-items: center
overflow: hidden
img
object-fit: cover
width: 100%
/* Type */
::selection
@ -629,6 +749,10 @@ button,
background-color: var(--color-accent)
.markdown-text
// Style inline links also if they're not in a paragraph
a
text-decoration: underline
hr
clear: both
@ -678,6 +802,7 @@ button,
.spoiler-alert
@include border-radius($border-radius)
backdrop-filter: blur(var(--filter-blur-value))
-webkit-backdrop-filter: blur(var(--filter-blur-value))
background: rgba(0, 0, 0, 0.4)
cursor: pointer
height: 100%
@ -726,13 +851,15 @@ button,
[data-tooltip]
&:hover
&:before, &:after
color: var(--color-text)
display: block
position: absolute
color: var(--color-text-primary)
&:before
border-radius: var(--spacer-1)
background-color: var(--color-bg-primary)
border-radius: var(--border-radius)
content: attr(title)
background-color: var(--color-bg-primary-subtle)
margin-top: var(--spacer)
padding: var(--spacer)
font-size: var(--fs-sm)
+fw-normal
margin-top: var(--spacer)
padding: var(--spacer-2)
word-break: break-word

View File

@ -12,7 +12,7 @@
&::-webkit-scrollbar-thumb
background: $bar-color
border-radius: $border-radius
border-radius: var(--border-radius)
border: 4px solid $bg-color
// TODO: Fix style
@ -50,7 +50,7 @@
.nav-drawer
background: var(--navbar-bg)
border-radius: $border-radius
border-radius: var(--border-radius)
border-width: 0 0 1px
display: none
flex-direction: column
@ -111,8 +111,12 @@
.nav-drawer-body
@include media-breakpoint-up(md)
display: flex
flex-direction: column
gap: var(--spacer-1)
height: 100%
max-height: calc(100vh - var(--nav-global-navbar-height))
+padding(2, bottom)
.nav-drawer-list
@include media-breakpoint-down(sm)
@ -145,15 +149,27 @@
transition: margin-left var(--nav-drawer-animation-duration)
.drawer-nav-group, .drawer-nav-header
h1, .h1, h2, .h2, h3, .h3, h4, .h4, h5, .h5, p, a
h1, .h1, h2, .h2, h3, .h3, h4, .h4, h5, .h5, h6, .h6, p, a
margin-bottom: 0
.drawer-nav-group
display: flex
flex-direction: column
gap: var(--spacer-1)
+padding(1, bottom)
.drawer-nav-list
background: var(--navbar-bg)
background-color: var(--color-bg-secondary)
border-radius: var(--border-radius)
color: var(--color-text-secondary)
display: flex
flex-direction: column
gap: var(--spacer-1)
list-style: none
margin: var(--spacer) / 4 0
padding: var(--spacer) / 4 0
+margin(2, x)
+margin(1, y)
+padding(1, x)
+padding(2, y)
&.training
.drawer-nav-section
@ -182,29 +198,40 @@
background-color: var(--color-accent)
.drawer-nav-section-icon
::before
&::before
background-color: var(--color-accent)
&:first-of-type
.drawer-nav-section-icon
::after
&::after
content: none
&:last-of-type
.drawer-nav-section-icon
::before
&::before
content: none
.drawer-nav-section-link
align-items: center
border-radius: var(--border-radius)
color: inherit
display: flex
flex-direction: row
flex-grow: 1
padding: calc(var(--spacer) * 0.5) var(--spacer)
transition: $transition-base
padding: var(--spacer-1)
margin: 0 var(--spacer-1)
transition: background-color var(--transition-speed), color var(--transition-speed)
width: 100%
&:hover
background: var(--color-bg-primary)
color: var(--color-text-primary)
&.active
background: var(--color-bg-primary)
color: var(--color-text-primary)
+fw-bold
.drawer-nav-section-icon-progress .progress
transition: $transition-base
@ -214,36 +241,13 @@
h4, .h4
margin-bottom: 0
line-height: 1.5
color: var(--color-text-secondary)
transition: $transition-base
span
line-height: 1
&::before
background: var(--color-bg-primary)
border-radius: $border-radius
content: close-quote
height: calc(100% - var(--spacer) / 2)
left: var(--spacer) / 2
opacity: 0
position: absolute
top: var(--spacer) / 4
transition: $transition-base
width: calc(100% - var(--spacer))
pointer-events: none
&:hover
text-decoration: none
.drawer-nav-section-icon-progress .progress
stroke: var(--color-accent)
// TODO: fix drawer-nav-section-link ::before
&::before
opacity: 1
// Fix before overflowing content on hover
i,
p
@ -252,13 +256,6 @@
>i
margin-right: (var(--spacer) / 2)
&.active
h4, .h4
.drawer-nav-section-icon
&-progress
.progress
stroke: var(--color-accent)
.subtitle
color:
font-size: var(--fs-xs)
@ -276,10 +273,9 @@
opacity: 1
.drawer-nav-header
margin-top: -$spacer / 4
border-bottom: var(--border-width) solid var(--box-bg-color)
margin-bottom: $spacer / 4
padding: $spacer
border-bottom: var(--border-width) solid var(--color-bg-alt)
+margin(3, x)
+padding(2, y)
@include media-breakpoint-down(sm)
padding: $spacer / 2 $spacer
@ -294,8 +290,9 @@
position: absolute
width: 24px
h5
span
font-size: var(--fs-sm)
+fw-bold
left: 50%
line-height: 0
position: absolute
@ -334,20 +331,18 @@ $circle-circumference: $circle-diameter * 3.14
.progress
fill: none
//not using the $progress-bg here as it's too strong, meant for overlaying images
// TODO: @web-sasets check variable $highlight-white-strong replacement
// stroke: $highlight-white-strong
stroke: var(--color-text-secondary)
stroke: var(--color-accent)
stroke-dasharray: $circle-circumference
stroke-dashoffset: calc((1 - var(--progress-fraction, 0)) * #{$circle-circumference}px)
stroke-linecap: round
stroke-width: 3px
stroke-width: 2px
.background
fill: none
// stroke: $highlight-white
stroke: var(--color-text-secondary)
stroke: currentColor
stroke-linecap: round
stroke-width: 3px
stroke-width: 1px
opacity: .33
.drawer-nav-dropdown-wrapper
@include button-float
@ -361,33 +356,32 @@ $circle-circumference: $circle-diameter * 3.14
.drawer-nav-dropdown
align-items: center
border-radius: var(--border-radius)
color: var(--nav-global-color-text)
cursor: pointer
display: flex
flex-grow: 1
margin-bottom: 0
margin: 0 var(--spacer-2)
max-width: 100%
padding: $spacer / 2 $spacer
padding: var(--spacer-1) var(--spacer-2)
position: relative
transition: $transition-base
transition: background-color var(--transition-speed), color var(--transition-speed)
user-select: none
&.dropdown
max-width: calc(100% - 44px)
&::before
// background: $highlight-white
&:hover
background: var(--color-bg-primary)
border-radius: $border-radius
content: close-quote
height: calc(100% - #{$spacer / 2})
left: $spacer / 2
opacity: 0
position: absolute
top: $spacer / 4
transition: $transition-base
width: calc(100% - #{$spacer})
pointer-events: none
color: var(--color-text-primary)
&.active
background: var(--color-bg-primary)
color: var(--color-text-primary)
+fw-bold
i
color: var(--color-text-primary)
&+.icon
color: var(--color-text-primary)
&.collapsed
i
@ -396,9 +390,6 @@ $circle-circumference: $circle-diameter * 3.14
&:hover
text-decoration: none
&::before
opacity: 1
// Fix before overflowing content on hover
i,
span
@ -414,13 +405,11 @@ $circle-circumference: $circle-diameter * 3.14
flex-grow: 0
flex-shrink: 1
justify-content: center
margin-left: - $spacer / 2
min-width: calc(var(--spacer) * 3)
padding: $spacer / 2 $spacer * .75
+padding(3, x)
margin-left: 0
+margin(right, 2)
.drawer-nav-dropdown-text
font-weight: normal
font-variation-settings: "wght" 400
margin-right: auto
.overflow-text

View File

@ -8,6 +8,7 @@
.bg-filter-blur
background-color: transparent
backdrop-filter: blur(24px)
-webkit-backdrop-filter: blur(24px)
position: relative
&::before

View File

@ -1,5 +1,8 @@
$container-max-widths: (sm: 100%, md: 100%, lg: 100%, xl: 1320px, xxl: 1600px)
// Redeclare $grid-breakpoints 'xl' and 'xxl' with web-assets defaults to override obsolete Bootstrap breakpoints coming from flat, pre-compiled vendor files
$grid-breakpoints: (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1320px, xxl: 1680px)
$container-width: map-get($container-max-widths, 'xl')
$font-path: "/static/assets/fonts"

View File

@ -18,12 +18,16 @@ html[data-theme="dark"]
/* Breadcrumb */
.breadcrumb-item
&.active
--btn-color: var(--color-text)
--btn-color: var(--color-text-secondary)
span
opacity: 1
/* Button */
.btn
&.active
@extend .btn-primary
.btn-admin
@extend .btn-link
@ -66,9 +70,6 @@ a
background-color: transparent
box-shadow: none
.cards
--grid-gap-size: calc(var(--spacer) * 2)
.cards-item
display: flex
flex-direction: column
@ -165,6 +166,15 @@ textarea
&.form-control
min-height: calc(var(--spacer) * 5)
/* Grid. */
// TODO: consider moving to web-assets
.container-fluid
.row
+margin(0, x)
.row
width: 100%
/* Hero. */
.hero-content
h1
@ -233,6 +243,18 @@ textarea
.nav-global-icon-dropdown-toggle
margin-left: 0
/* Notifications. */
.notifications
--border-width: .1rem
.notifications-item-content
em
font-style: normal
+fw-bold
.notifications-list
width: 100%
/* Pagination */
// Fix hover colours when btn is not in box for web-assets
.pagination

View File

@ -130,6 +130,12 @@ def get_s3_post_url_and_fields(
return response
_storages = {
's3': S3Boto3CustomStorage(),
'fs': nginx_secure_links.storages.FileStorage(),
}
class DynamicStorageFieldFile(FieldFile):
"""Defines which storage the file is located at."""
@ -137,9 +143,9 @@ class DynamicStorageFieldFile(FieldFile):
"""Choose between S3 and file system storage depending on `source_storage`."""
super().__init__(instance, *args, **kwargs)
if instance.source_storage is None: # S3 is default
self.storage = S3Boto3CustomStorage()
self.storage = _storages['s3']
elif instance.source_storage == 'fs':
self.storage = nginx_secure_links.storages.FileStorage()
self.storage = _storages['fs']
else:
raise
@ -152,9 +158,9 @@ class CustomFileField(models.FileField):
def pre_save(self, model_instance, add):
"""Choose between S3 and file system storage depending on `source_storage`."""
if model_instance.source_storage is None:
storage = S3Boto3CustomStorage()
storage = _storages['s3']
elif model_instance.source_storage == 'fs':
storage = nginx_secure_links.storages.FileStorage()
storage = _storages['fs']
else:
raise
self.storage = storage

View File

@ -17,13 +17,11 @@
{% endif %}
{{ post.title }}
</h3>
{% comment %}
<div class="cards-item-excerpt">
<p>
{{ post.excerpt }}
</p>
</div>
{% endcomment %}
{% if post.excerpt %}
<div class="cards-item-excerpt">
<p>{{ post.excerpt }}</p>
</div>
{% endif %}
<div class="d-flex cards-item-extra">
{% if not post.is_published %}
<span class="badge badge-danger me-3">Unpublished</span>

View File

@ -4,34 +4,34 @@
<div class="row">
<div class="col">
<div class="fs-xs letter-spacing lh-1 text-muted text-uppercase mb-3">
Highlighted trainings
Training Highlights
</div>
</div>
</div>
<div class="row">
<a class="col-6 d-flex pb-3" href="/training/geometry-nodes-from-scratch">
<a class="col-6 d-flex align-items-center pb-3" href="/training/geometry-nodes-from-scratch">
<div class="me-3 nav-subnav-item-img">
<div class="bg-center bg-cover rounded" style="background-image: url('{% static 'common/images/welcome/training-thumbnail-geometry-nodes-orig.jpg' %}');"></div>
</div>
<h6 class="fs-sm fw-normal lh-sm mb-0">Geometry nodes from scratch</h6>
<h6 class="fw-normal lh-xs mb-0">Geometry Nodes from Scratch</h6>
</a>
<a class="col-6 d-flex pb-3" href="/training/procedural-shading">
<a class="col-6 d-flex align-items-center pb-3" href="/training/procedural-shading">
<div class="me-3 nav-subnav-item-img">
<div class="bg-center bg-cover rounded" style="background-image: url('{% static 'common/images/welcome/training-thumbnail-procedural-shading-orig.jpg' %}');"></div>
</div>
<h6 class="fs-sm fw-normal lh-sm mb-0">Procedural shading: Fundamentals and beyond</h6>
<h6 class="fw-normal lh-xs mb-0">Procedural Shading Fundamentals</h6>
</a>
<a class="col-6 d-flex pb-3" href="/training/stylized-character-workflow">
<a class="col-6 d-flex align-items-center pb-3" href="/training/stylized-character-workflow">
<div class="me-3 nav-subnav-item-img">
<div class="bg-center bg-cover rounded" style="background-image: url('{% static 'common/images/welcome/training-thumbnail-stylized-character-workflow-orig.jpg' %}' );"></div>
</div>
<h6 class="fs-sm fw-normal lh-sm mb-0">Stylized character workflow</h6>
<h6 class="fw-normal lh-xs mb-0">Stylized Character Workflow</h6>
</a>
<a class="col-6 d-flex pb-3" href="/training/animation-fundamentals">
<a class="col-6 d-flex align-items-center pb-3" href="/training/animation-fundamentals">
<div class="me-3 nav-subnav-item-img">
<div class="bg-center bg-cover rounded" style="background-image: url('{% static 'common/images/welcome/training-thumbnail-animation-fundamentals-orig.jpg' %}');"></div>
</div>
<h6 class="fs-sm fw-normal lh-sm mb-0">Animation fundamentals</h6>
<h6 class="fw-normal lh-xs mb-0">Animation Fundamentals</h6>
</a>
</div>
<hr>
@ -47,7 +47,7 @@
<div class="btn-row">
<a class="btn btn-secondary btn-sm" href="/training/?training_date_desc%5Bmenu%5D%5Btype%5D=course#all-training">Course</a>
<a class="btn btn-secondary btn-sm" href="/training/?training_date_desc%5Bmenu%5D%5Btype%5D=documentation#all-training">Documentation</a>
<a class="btn btn-secondary btn-sm" href="/training/?training_date_desc%5Bmenu%5D%5Btype%5D=production%20lesson#all-training">Production lesson</a>
<a class="btn btn-secondary btn-sm" href="/training/?training_date_desc%5Bmenu%5D%5Btype%5D=production%20lesson#all-training">Production Lesson</a>
<a class="btn btn-secondary btn-sm" href="/training/?training_date_desc[menu][type]=workshop#all-training">Worskhop</a>
</div>
</div>
@ -74,3 +74,54 @@
</div>
</div>
</div>
<div class="d-md-block d-none fade js-nav-subnav-film nav-subnav position-absolute">
<div class="bg-filter-blur bg-noise box mt-2">
<div class="row">
<div class="col">
<div class="fs-xs letter-spacing lh-1 text-muted text-uppercase mb-3">
Film Highlights
</div>
</div>
</div>
<div class="row">
<a class="col-6 d-flex align-items-center pb-3" href="/films/gold/">
<div class="me-3 nav-subnav-item-img">
<div class="bg-center bg-cover rounded" style="background-image: url('{% static 'common/images/welcome/film-thumbnail-gold.webp' %}');"></div>
</div>
<div>
<h6 class="fw-normal lh-xs mb-0">Gold</h6>
<small class="badge badge-sm badge-primary">In Production</small>
</div>
</a>
<a class="col-6 d-flex align-items-center pb-3" href="/films/wing-it/">
<div class="me-3 nav-subnav-item-img">
<div class="bg-center bg-cover rounded" style="background-image: url('{% static 'common/images/welcome/film-thumbnail-wing-it.webp' %}');"></div>
</div>
<div>
<h6 class="fw-normal lh-xs mb-0">Wing It!</h6>
<small class="text-muted">2023</small>
</div>
</a>
<a class="col-6 d-flex align-items-center" href="/films/charge/">
<div class="me-3 nav-subnav-item-img">
<div class="bg-center bg-cover rounded" style="background-image: url('{% static 'common/images/welcome/film-thumbnail-charge.webp' %}' );"></div>
</div>
<div>
<h6 class="fw-normal lh-xs mb-0">Charge</h6>
<small class="text-muted">2022</small>
</div>
</a>
<a class="col-6 d-flex align-items-center" href="/films/sprite-fright/">
<div class="me-3 nav-subnav-item-img">
<div class="bg-center bg-cover rounded" style="background-image: url('{% static 'common/images/welcome/film-thumbnail-sprite-fright.webp' %}');"></div>
</div>
<div>
<h6 class="fw-normal lh-xs mb-0">Sprite Fright</h6>
<small class="text-muted">2021</small>
</div>
</a>
</div>
</div>
</div>

View File

@ -22,10 +22,10 @@
<ul class="nav-global-nav-links nav-global-dropdown js-dropdown-menu" id="nav-global-nav-links">
<li>
<a href="{% url 'film-list' %}" class="{% if '/films' in request.path %}is-active{% endif %}">Films</a>
<a href="{% url 'film-list' %}" data-subnav=".js-nav-subnav-film" class="js-nav-global-subnav-link {% if '/films' in request.path %}is-active{% endif %}">Films</a>
</li>
<li>
<a href="{% url 'training-home' %}" class="js-nav-global-link-training {% if '/training' in request.path %}is-active{% endif %}">Training</a>
<a href="{% url 'training-home' %}" data-subnav=".js-nav-subnav-training" class="js-nav-global-subnav-link {% if '/training' in request.path %}is-active{% endif %}">Training</a>
</li>
<li>
<a href="{% url 'character-list' %}" class="{% if '/characters' in request.path %}is-active{% endif %}">Characters</a>

View File

@ -7,29 +7,29 @@
<span>{{ user.notifications_unread.count }}</span>
{% endif %}
</button>
<div class="dropdown-menu dropdown-menu-end dropdown-menu-notification">
<div class="btn-row">
<a href="{% url 'user-notification' verbs='commented,replied to' %}" class="dropdown-item flex">
<span>Notifications</span>
</a>
<a class="btn btn-link dropdown-item flex-grow-0" data-bs-toggle="tooltip" data-placement="top" data-mark-all-read-url="{% url 'api-notifications-mark-read' %}" title="Mark all as read">
<i class="i-check me-0"></i>
<div class="dropdown-menu dropdown-menu-end dropdown-menu-notification mt-2 p-0 theme-dark">
<div class="bg-filter-blur bg-noise box pt-3">
<div class="align-items-center d-flex">
<div class="flex-grow-1 fs-xs letter-spacing lh-1 text-muted text-uppercase">Notifications</div>
<a class="btn btn-link dropdown-item flex-grow-0" data-bs-toggle="tooltip" data-placement="top" data-mark-all-read-url="{% url 'api-notifications-mark-read' %}" title="Mark all as read">
<i class="i-check me-0"></i>
</a>
</div>
<ul class="notifications-list mb-3">
{% for notification in user.notifications.all|slice:":10" %}
{% with action=notification.action %}
{% include 'users/components/nav_action.html' %}
{% endwith %}
{% empty %}
<p class="mb-0 text-muted">
No notifications yet
</p>
{% endfor %}
</ul>
<a href="{% url 'user-notification' %}">
<span>See all notifications</span>
</a>
</div>
<div class="dropdown-menu-nested">
{% for notification in user.notifications.all|slice:":10" %}
{% with action=notification.action %}
{% include 'users/components/nav_action.html' %}
{% endwith %}
{% empty %}
<p class="px-2 py-2 text-center text-muted">
No notifications yet
</p>
{% endfor %}
</div>
<a href="{% url 'user-notification' %}" class="dropdown-item text-sm">
<span>See all notifications</span>
</a>
</div>
</div>
</li>

View File

@ -72,8 +72,8 @@
</a>
</li>
<li>
<a href="https://twitter.com/BlenderStudio_" title="Follow Blender Studio on Twitter" target="_blank" class="social-icons__twitter">
<i class="i-twitter"></i>Twitter
<a href="https://x.com/BlenderStudio_" title="Follow Blender Studio on X" target="_blank" class="social-icons__twitter">
<i class="i-twitter"></i>X
</a>
</li>
<li>

View File

@ -3,7 +3,7 @@
<a href="{{ href }}" class="drawer-nav-section-link justify-content-between {% if active %}active{% endif %}" data-bs-tooltip="tooltip-overflow"
data-placement="top" title="{{ title }}">
<div class="nav-drawer-section-progress-wrapper">
<h5>{{ nth }}</h5>
<span>{{ nth }}</span>
{% comment %} TODO(Anna): Fix fraction calculation {% endcomment %}
<svg width="40" height="40" class="drawer-nav-section-icon-progress" style="--progress-fraction: {% if finished %} 1.0 {% else %} {{ progress_fraction }} {% endif %}">
<circle class="background" cx="20" cy="20" r="14" />

View File

@ -6,19 +6,21 @@
<a href="?page=1">First</a>
</li>
{% endif %}
<li class="page-item">
<a href="?page={{ page_obj.previous_page_number }}">{{ page_obj.previous_page_number }}</a>
<li class="page-item page-prev">
<a href="?page={{ page_obj.previous_page_number }}"><i class="i-chevron-left"></i> Previous</a>
</li>
{% endif %}
<li class="active disabled page-item">
<a href="#">{{ page_obj.number }}</a>
<li class="page-item page-current">
<a href="#">Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}</a>
</li>
{% if page_obj.has_next %}
<li class="page-item">
<a href="?page={{ page_obj.next_page_number }}">{{ page_obj.next_page_number }}</a>
<li class="page-item page-next">
<a href="?page={{ page_obj.next_page_number }}">Next <i class="i-chevron-right"></i></a>
</li>
{% if not page_obj.next_page_number == page_obj.paginator.num_pages %}
<li class="page-item page-last">
<a href="?page={{ page_obj.paginator.num_pages }}">Last</a>

View File

@ -1,10 +1,9 @@
#!/bin/sh -ex
git fetch origin main:production
git push origin production
git fetch origin main:production && git push origin production
pushd playbooks
source .venv/bin/activate
./ansible.sh -i environments/production deploy.yaml
./ansible.sh -i environments/production shared/deploy.yaml
deactivate
popd

View File

@ -40,9 +40,8 @@ or [venv](https://docs.python.org/3.10/library/venv.html) will do.
- Download the CloudFront key file and save it to the project directory (it should be named `pk-APK***.pem`);
- Set `AWS_CLOUDFRONT_KEY_ID='APK***'` where `APK***` is from the name of the key file above.
6. In the project folder, run migrations and load additional plans data:
./manage.py migrate
./manage.py loaddata team_plans
- `./manage.py migrate`
- `./manage.py loaddata team_plans`
7. Create a superuser: `echo "from django.contrib.auth import get_user_model; User = get_user_model(); User.objects.create_superuser('admin', 'admin@example.com', 'password')" | python manage.py shell`
8. Run the server: `./manage.py runserver 8001`. The project will be available at
@ -53,7 +52,7 @@ or [venv](https://docs.python.org/3.10/library/venv.html) will do.
The default domain is `example.com`; change it to `studio.local:8001`. This will make
it possible to immediately view objects created/edited via admin on site.
11. Set up the [Blender ID server](#blender-id-authentication) for authentication
and [MeiliSerach server](#search) for the search functionality.
and [MeiliSearch server](#search) for the search functionality.
12. Setup for video processing jobs. Download ngrok (https://ngrok.com/).
- Run `./ngrok http 8010`
- Update `.env`:

View File

@ -13,6 +13,7 @@ const search = instantsearch({
return {
query: indexUiState.query,
sortBy: indexUiState && indexUiState.sortBy,
media_type: indexUiState.menu && indexUiState.menu.media_type,
};
},
routeToState(routeState) {
@ -20,6 +21,9 @@ const search = instantsearch({
[indexName]: {
query: routeState.query,
sortBy: routeState.sortBy,
menu: {
media_type: routeState.media_type,
},
},
};
},
@ -117,16 +121,21 @@ const renderHits = (renderOptions, isFirstRender) => {
<div class="cards-item-thumbnail">
<img aria-label="${item.name}" loading=lazy src="${item.thumbnail_url || fileIconURL}">
</div>
<h3 class="cards-item-title fs-6 lh-base overflow-text" data-tooltip="tooltip-overflow" data-placement="top" title="${item.name}">
${instantsearch.highlight({
attribute: 'name',
hit: item,
})}
<h3 class="cards-item-title fs-6 lh-base" data-tooltip="tooltip-overflow" data-placement="top" title="${item.name}">
<span class="overflow-text">
${instantsearch.highlight({
attribute: 'name',
hit: item,
})}
</span>
</h3>
<div class="cards-item-extra">
<ul>
<li>
<i class="i-clock x-sm"></i>&nbsp; ${timeDifference(epochToDate(item.timestamp))}
${item.media_type}
</li>
<li>
<i class="i-clock x-sm"></i> ${timeDifference(epochToDate(item.timestamp))}
</li>
<li>
${
@ -179,6 +188,47 @@ const renderHits = (renderOptions, isFirstRender) => {
const customHits = instantsearch.connectors.connectInfiniteHits(renderHits);
// -------- FILTERS -------- //
// 1. Create a render function
const renderMenuSelect = (renderOptions, isFirstRender) => {
const { items, canRefine, refine, widgetParams } = renderOptions;
if (isFirstRender) {
const select = document.createElement('select');
select.setAttribute('class', 'form-control');
select.addEventListener('change', (event) => {
refine(event.target.value);
});
widgetParams.container.insertAdjacentElement('afterbegin', select);
// widgetParams.container.appendChild(select);
}
const select = widgetParams.container.querySelector('select');
select.disabled = !canRefine;
select.innerHTML = `
<option value="">${widgetParams.placeholder}</option>
${items
.map(
(item) =>
`<option
value="${item.value}"
${item.isRefined ? 'selected' : ''}
>
${titleCase(item.label)}
</option>`
)
.join('')}
`;
};
// 2. Create the custom widget
const customMenuSelect = instantsearch.connectors.connectMenu(renderMenuSelect);
// -------- CONFIGURE -------- //
const renderConfigure = (renderOptions, isFirstRender) => {};
@ -202,6 +252,11 @@ search.addWidgets([
{ label: 'Date (old first)', value: 'studio_date_asc' },
],
}),
customMenuSelect({
container: document.querySelector('#searchMedia'),
attribute: 'media_type',
placeholder: 'All Types',
}),
customConfigure({
container: document.querySelector('#hits'),
searchParameters: {

View File

@ -14,28 +14,28 @@
<header class="navbar navbar-secondary" role="navigation">
<div class="container">
<ul class="navbar-nav">
<li class="nav-item nav-parent show-on-scroll" data-link="/">
<a class="nav-link" href="/">Home</a>
</li>
<li class="nav-item" data-link="{% url 'film-detail' film_slug=film.slug %}">
<a class="nav-link" href="{% url 'film-detail' film_slug=film.slug %}">{{ film.title }}</a>
<a class="nav-link" href="{% url 'film-detail' film_slug=film.slug %}"><strong>{{ film.title }}</strong></a>
</li>
<li class="nav-separator"><i class="i-chevron-right"></i></li>
<li class="nav-item {% if current_collection or '/all-artwork/' in request.path %}active{% endif %}" data-link="{% url 'film-gallery' film_slug=film.slug %}">
<a class="nav-link" href="{% url 'film-all-assets' film_slug=film.slug %}">Content Gallery</a>
</li>
{% if film.show_production_logs_nav_link %}
<li class="nav-item {% if date_list or production_log.name or '/production-log' in request.path %}active{% endif %}">
<a class="nav-link" href="{% url 'film-production-logs' film_slug=film.slug %}">Production Logs</a>
</li>
{% if user.is_staff and user_can_edit_production_log %}
<li>
{% include 'films/components/admin/production_log_manage.html' %}
</li>
{% endif %}
{% endif %}
{% for flatpage in film.flatpages.all %}
<li class="nav-item" data-link="{% url 'film-flatpage' film_slug=film.slug page_slug=flatpage.slug %}">
<a class="nav-link" href="{% url 'film-flatpage' film_slug=film.slug page_slug=flatpage.slug %}">{{ flatpage.title|title }}</a>
</li>
{% endfor %}
<li class="nav-item {% if current_collection %}active{% endif %}" data-link="{% url 'film-gallery' film_slug=film.slug %}">
<a class="nav-link" href="{% url 'film-gallery' film_slug=film.slug %}">Content Gallery</a>
</li>
{% if film.show_production_logs_nav_link %}
<li class="nav-item {% if date_list or production_log.name %}active{% endif %}">
<a class="nav-link" href="{% url 'film-production-logs' film_slug=film.slug %}">Production Logs</a>
</li>
<li class="ms-3">
{% include 'films/components/admin/production_log_manage.html' %}
</li>
{% endif %}
{% if user_has_production_credit or credit %}
<li class="nav-item" data-link="{% url 'production-credit' film_slug=film.slug %}">
<a href="{% url 'production-credit' film_slug=film.slug %}" class="nav-link">

View File

@ -1,5 +1,4 @@
{% if user.is_staff and user_can_edit_production_log %}
<a data-bs-toggle="dropdown" class="btn btn-admin">
<a data-bs-toggle="dropdown" class="btn btn-admin nav-link">
<i class="i-more-vertical"></i>
</a>
<div class="dropdown-menu dropdown-menu-end">
@ -7,4 +6,3 @@
<a href="{% url 'admin:films_productionlog_changelist' %}?film__id__exact={{ film.id }}"
class="dropdown-item">Manage Production Logs</a>
</div>
{% endif %}

View File

@ -5,7 +5,11 @@
</div>
<h3 class="cards-item-title">
<span class="me-2">{{ film.title }}</span>
{% if film.status == "2_released" %}
{% if film.status == "1_prod" %}
<span class="badge badge-primary">
{{ film.get_status_display }}
</span>
{% elif film.status == "2_released" %}
<span class="badge">
{{ film.release_date|date:"Y" }}
</span>

View File

@ -10,7 +10,8 @@
{% if film.youtube_link != "" %}
{# Class 'video-modal-link' is needed for js #}
<button class="btn btn-accent video-modal-link" data-bs-toggle="modal" data-bs-target="#videoModal" data-video="{{ film.youtube_link }}">
Watch {{ film.title }}
<i class="i-youtube"></i>
<span>Watch {{ film.title }}</span>
</button>
<a class="btn btn-link" href="{% url 'film-gallery' film.slug %}">Explore Content Gallery</a>
{% else %}

View File

@ -2,41 +2,48 @@
{% load common_extras %}
<div>
<div class="pb-4 pt-2">
{% if user_can_edit_production_log %}
<a href="{{ production_log.admin_url }}" class="btn btn-admin mb-3">
<i class="i-edit"></i>
<span>Edit</span>
</a>
{% endif %}
<div class="py-2">
<div class="row">
<div class="col-md-8">
<div class="row">
<div class="col-md-12">
<h2>
<h2 class="mb-0">
<a href="{{ production_log.url }}" class="text-white">
{{ production_log.name }}
</a>
</h2>
{% if production_log.start_date %}
<div class="text-muted mb-2">
<small>
<div class="text-muted mb-2">
<ul class="list-inline mb-0">
{% if production_log.start_date %}
<li>
{{ production_log.start_date|date:'N jS, Y' }}
</small>
</div>
{% endif %}
</li>
{% endif %}
{% if user_can_edit_production_log %}
<li>
-
<a href="{{ production_log.admin_url }}" class="text-muted text-underline">
Edit
</a>
</li>
{% endif %}
</ul>
</div>
{% if production_log.summary %}
<div>
<span class="fw-bold">This week on {{ film.title }}:</span>
<strong>This week on {{ film.title }}</strong>
{% with_shortcodes production_log.summary|markdown %}
</div>
{% if production_log.youtube_link != "" %}
<a href="{{ production_log.youtube_link }}" class="btn btn-primary mt-3 video-modal-link" data-bs-toggle="modal"
data-bs-target="#videoModal" data-video="{{ production_log.youtube_link }}">Watch Video</a>
data-bs-target="#videoModal" data-video="{{ production_log.youtube_link }}">
<i class="i-youtube"></i>
<span>Watch Video</span>
</a>
{% endif %}
{% endif %}
</div>
@ -66,56 +73,60 @@
<div>
{% for entry in production_log.log_entries.all %}
<div class="pb-4">
<div class="row ">
<div class="col-md-2">
{% include 'common/components/cards/card_profile.html' with user=entry.user title=entry.author_role %}
{% with entry_author=entry.author|default:entry.user contributors=entry.contributors first_contributor=entry.contributors|first %}
{% if contributors|length > 1 or contributors|length == 1 and first_contributor.pk != entry_author.pk %}
<h4 class="fs-6 fw-normal lh-base mt-3 text-muted">Other contributors:</h4>
<div class="align-items-center contributors d-flex mb-1">
<div class="d-flex flex-wrap">
{% for contributor in contributors %}
{% if contributor.pk != entry_author.pk %}
{% include 'users/components/avatar.html' with user=contributor %}
{% endif %}
{% endfor %}
</div>
</div>
{% endif %}
{% endwith %}
<div class="row">
<div class="col-md-12">
<div class="d-flex">
<div>
{% include 'common/components/cards/card_profile.html' with user=entry.user title=entry.author_role %}
</div>
<div class="ms-4 pt-1">
{% with entry_author=entry.author|default:entry.user contributors=entry.contributors first_contributor=entry.contributors|first %}
{% if contributors|length > 1 or contributors|length == 1 and first_contributor.pk != entry_author.pk %}
<div class="align-items-center contributors d-flex mb-1">
<div class="d-flex flex-wrap">
{% for contributor in contributors %}
{% if contributor.pk != entry_author.pk %}
{% include 'users/components/avatar.html' with user=contributor %}
{% endif %}
{% endfor %}
</div>
</div>
{% endif %}
{% endwith %}
</div>
{% if user_can_edit_production_log_entry %}
<div class="ms-auto">
<a href="{{ entry.admin_url }}" class="btn btn-admin">
<i class="i-edit"></i>
<span>Edit</span>
</a>
</div>
{% endif %}
</div>
</div>
<div class="col-md-10">
</div>
<div class="row ">
<div class="col-md-12">
<div class="flex-column-reverse flex-md-row row">
<div class="col-md-8 pb-3">
<div class="col-md-9 py-3">
<p>{{ entry.description }}</p>
</div>
{% if user_can_edit_production_log_entry %}
<div class="col-md-4 d-flex justify-content-md-end">
<div class="pb-3">
<a href="{{ entry.admin_url }}" class="btn btn-admin">
<i class="i-edit"></i>
<span>Edit</span>
</a>
</div>
</div>
{% endif %}
</div>
<div class="files">
<div class="row">
<div class="col">
<div class="card-layout-card-transparent cards">
{% for asset in entry.assets.all|slice:':3' %}
<div class="card-layout-card-transparent cards cards-4">
{% for asset in entry.assets.all|slice:':8' %}
{% if asset.is_published %}
{% include "common/components/file.html" with card_sizes="col" aspect_ratio="16:9" asset=asset site_context="production_logs" %}
{% endif %}
{% endfor %}
</div>
{% if entry.assets.count > 3 %}
{% if entry.assets.count > 8 %}
<div class="collapse" id="entry-{{ entry.id }}">
<div class="card-layout-card-transparent cards">
{% for asset in entry.assets.all|slice:'3:' %}
<div class="card-layout-card-transparent cards cards-4">
{% for asset in entry.assets.all|slice:'8:' %}
{% if asset.is_published %}
{% include "common/components/file.html" with card_sizes="col" aspect_ratio="16:9" asset=asset site_context="production_logs" %}
{% endif %}
@ -133,15 +144,15 @@
</div>
</div>
</div>
<hr>
<hr style="border-width: 1px;">
</div>
{% endfor %}
</div>
{% if user_can_edit_production_log %}
<div class="mt-3 text-center">
<a class="btn btn-admin px-5" href="{% url 'admin:films_productionlogentry_add' %}?production_log={{ production_log.pk }}">
<i class="i-plus me-2"></i>
<div class="mt-3 text-right">
<a class="btn btn-admin" href="{% url 'admin:films_productionlogentry_add' %}?production_log={{ production_log.pk }}">
<i class="i-plus"></i>
<span>Add Entry</span>
</a>
</div>

View File

@ -36,14 +36,14 @@
<!-- Latest Updates -->
<div class="row mb-3">
<div class="col text-center">
<h1>This week in Production</h1>
<p class="mb-0">Check out what the team has been working these days on {{ film.title }}.
<h1>Production Logs</h1>
<p class="mb-0">Check out the latest updates on {{ film.title }}.
<a href="{% url 'film-production-logs' film.slug %}">See all production logs</a>
</p>
</div>
</div>
<div>
{% for production_log in production_logs_page|slice:":1" %}
{% for production_log in production_logs_page|slice:":4" %}
{% include 'films/components/production_log_entry.html' %}
{% endfor %}
</div>

View File

@ -45,7 +45,7 @@
</div>
<div class="drawer-nav-dropdown-wrapper">
<a class="drawer-nav-dropdown fw-bold" href="{% url 'film-all-assets' film_slug=film.slug %}">
<a class="drawer-nav-dropdown" href="{% url 'film-all-assets' film_slug=film.slug %}">
<i class="i-search me-2"></i>
All Artwork
</a>
@ -133,17 +133,17 @@
{% block nested_nav_drawer_inner %}
<div class="drawer-nav-group">
<div class="drawer-nav-dropdown-wrapper">
<a class="drawer-nav-dropdown fw-bold" href="{% url 'film-gallery' film_slug=film.slug %}"
data-bs-tooltip="tooltip-overflow" data-placement="top" title="Featured Artwork">
<i class="i-star me-2"></i>
<span class="overflow-text">Featured Artwork</span>
<a class="drawer-nav-dropdown" href="{% url 'film-all-assets' film_slug=film.slug %}"
data-bs-tooltip="tooltip-overflow" data-placement="top" title="Search Project">
<i class="i-search me-2"></i>
<span class="overflow-text">Search Project</span>
</a>
</div>
<div class="drawer-nav-dropdown-wrapper">
<a class="drawer-nav-dropdown fw-bold" href="{% url 'film-all-assets' film_slug=film.slug %}"
data-bs-tooltip="tooltip-overflow" data-placement="top" title="All Artwork">
<i class="i-search me-2"></i>
<span class="overflow-text">All Artwork</span>
<a class="drawer-nav-dropdown" href="{% url 'film-gallery' film_slug=film.slug %}"
data-bs-tooltip="tooltip-overflow" data-placement="top" title="Featured Artwork">
<i class="i-star me-2"></i>
<span class="overflow-text">Featured Artwork</span>
</a>
</div>
</div>
@ -151,7 +151,7 @@
{% for collection, child_collections in collections.items %}
<div class="drawer-nav-dropdown-wrapper">
{% if child_collections %}
<a class="drawer-nav-dropdown fw-bold dropdown" href="{{ collection.url }}"
<a class="drawer-nav-dropdown dropdown" href="{{ collection.url }}"
data-bs-tooltip="tooltip-overflow" data-placement="top" title="{{ collection.name }}">
<span class="drawer-nav-dropdown-text overflow-text">
{{ collection.name }}
@ -162,7 +162,7 @@
<i class="i-chevron-down"></i>
</a>
{% else %}
<a class="drawer-nav-dropdown fw-bold" href="{{ collection.url }}" data-bs-tooltip="tooltip-overflow"
<a class="drawer-nav-dropdown" href="{{ collection.url }}" data-bs-tooltip="tooltip-overflow"
data-placement="top" title="{{ collection.name }}">
<span class="drawer-nav-dropdown-text overflow-text">
{{ collection.name }}

View File

@ -15,13 +15,11 @@
{% block toolbar %}
<div class="row mb-3">
<div class="col">
{% include "search/components/input.html" with sm=True %}
{% include "search/components/input.html" %}
</div>
<div class="col-auto mb-3 mb-md-0 d-md-flex d-none">
<div class="input-group input-group-sm" id="sorting">
<label class="input-group-text pe-0" for="searchLicence">Sort by:</label>
{% comment %} INPUT (Js) {% endcomment %}
</div>
<div class="input-group" id="searchMedia"></div>
<div class="input-group ms-3" id="sorting"></div>
</div>
</div>
{% endblock toolbar %}

View File

@ -1,3 +1,6 @@
{# TODO: Remove template #}
{# This template is used by ProductionLogView, which is not reachable by any route. #}
{# The route has been replaced by ProductionLogPaginatedView #}
{% extends 'films/base_films.html' %}
{% load static %}
@ -15,22 +18,17 @@
<p>Follow the latest updates and progress on {{ film.title }}.</p>
</div>
<div class="col-md-6 d-flex justify-content-md-end">
{% with previous_month=date_list.1 %}
{% include 'films/components/pagination_dates.html' %}
{% endwith %}
{% include "common/components/navigation/pagination.html" %}
</div>
</div>
{% if latest_month|length %}
{% if object_list %}
<div>
{% for production_log in latest_month %}
{% for production_log in object_list %}
{% include 'films/components/production_log_entry.html' %}
{% endfor %}
</div>
{% with previous_month=date_list.1 %}
{% include 'films/components/pagination_dates.html' %}
{% endwith %}
{% include "common/components/navigation/pagination.html" %}
{% else %}
<div class="row">
<div class="col text-center">

View File

@ -0,0 +1,39 @@
{% extends 'films/base_films.html' %}
{% load static %}
{% block title_prepend %}Production Logs - {{ film.title }} - {% endblock title_prepend %}
{% block bodyclasses %}spacer has-secondary-nav{% endblock bodyclasses %}
{% block content %}
<section class="mb-5">
<div class="container pt-4">
<!-- Latest Updates -->
<div class="row">
<div class="col-md-6 mb-3">
<h1 class="mb-1">Latest on {{ film.title }}</h1>
</div>
<div class="col-md-6 d-flex justify-content-md-end">
{% include "common/components/navigation/pagination.html" %}
</div>
</div>
{% if object_list %}
<div>
{% for production_log in object_list %}
{% include 'films/components/production_log_entry.html' %}
{% endfor %}
</div>
{% include "common/components/navigation/pagination.html" %}
{% else %}
<div class="row">
<div class="col text-center">
<div class="bg-secondary py-4 rounded">
<h3 class="mb-0">No Weeklies to show</h3>
</div>
</div>
</div>
{% endif %}
</div>
</section>
{% endblock content %}

View File

@ -26,7 +26,7 @@ urlpatterns = [
),
path(
'<slug:film_slug>/production-logs/',
production_log.ProductionLogView.as_view(),
production_log.ProductionLogPaginatedView.as_view(),
name='film-production-logs',
),
path(

View File

@ -4,9 +4,10 @@ from django.http import Http404
from django.shortcuts import get_object_or_404
from django.shortcuts import redirect
from django.urls.base import reverse
from django.views.generic import dates, detail
from django.views.generic import dates, detail, ListView
from common.queries import has_active_subscription
from common.mixins import PaginatedViewMixin
from films.models import Film, ProductionLog
from films.queries import (
get_next_production_log,
@ -197,3 +198,13 @@ class ProductionLogMonthView(_ProductionLogViewMixin, LandingPageMixin, dates.Mo
# Make sure `date_list` is an actual list, not a QuerySet, otherwise `|last` won't work
context['date_list'] = list(date_list)
return context
class ProductionLogPaginatedView(_ProductionLogViewMixin, LandingPageMixin, PaginatedViewMixin):
model = ProductionLog
context_object_name = 'production_log'
paginate_by = 4
def get_queryset(self) -> QuerySet:
film = get_object_or_404(Film, slug=self.kwargs['film_slug'], is_published=True)
return get_production_logs(film)

View File

@ -22,7 +22,7 @@ To set it up use the following commands:
python3.10 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
pip install -r shared/requirements.txt
## First time install
@ -38,8 +38,8 @@ One of these variables is `meili_master_key`, which can be generated using the f
After encrypting `meili_master_key` and saving in the above mentioned `99_vault.yaml`,
run the installation playbooks:
./ansible.sh -i environments/production install.yaml --vault-id production@prompt
./ansible.sh -i environments/production setup_certificate.yaml
./ansible.sh -i environments/production shared/install.yaml --vault-id production@prompt
./ansible.sh -i environments/production shared/setup_certificate.yaml
These vaulted variables are written to the configuration files at the target host,
so they shouldn't be required after the installation is complete,
@ -56,7 +56,7 @@ editing it and then restarting the affected services:
### Encrypting variables
Let's say one of the config templates used by `install.yaml` refers to a variable named `sentry_dsn`,
Let's say one of the config templates used by `shared/install.yaml` refers to a variable named `sentry_dsn`,
and for **production** we want this variable to have the following value: `https://foo@bar.example.com/1234`.
To encrypt this value, use the following command:
@ -96,8 +96,21 @@ When you need to deploy something, make sure to commit and push your changes bot
git fetch origin main:production && git push origin production
```
3. navigate to the playbooks and run `deploy.yaml`
3. navigate to the playbooks and run `shared/deploy.yaml`
```
./ansible.sh -i environments/production deploy.yaml
./ansible.sh -i environments/production shared/deploy.yaml
```
### Periodic tasks
Blender Studio is using systemd timers for periodic tasks such as cleaning up old sessions,
processing account deletion requests and charging subscriptions.
To install or update these, use the following playbook:
./ansible.sh -i environments/production shared/install.yaml --tags=services
To view existing timers at the target host, the following can be used:
systemctl list-units --type=timer | grep blender-studio

View File

@ -1,12 +0,0 @@
[ssh_connection]
ssh_args = -o ServerAliveInterval=30 -o ControlMaster=auto -o ControlPersist=60s
pipelining = True
[defaults]
error_on_missing_handler = True
error_on_undefined_vars = True
# Use the YAML callback plugin.
stdout_callback = yaml
# Use the stdout_callback when running ad-hoc commands.
bin_ansible_callbacks = True
ansible_python_interpreter = /usr/bin/python3

1
playbooks/ansible.cfg Symbolic link
View File

@ -0,0 +1 @@
shared/ansible.cfg

View File

@ -1,8 +0,0 @@
#!/bin/bash
# This script will only work when called by a user that also exists
# at the target host and is capable of `sudo`.
# "-K" is necessary because ansible has to prompt
# for a password when becoming a required user.
.venv/bin/ansible-playbook -K $@

1
playbooks/ansible.sh Symbolic link
View File

@ -0,0 +1 @@
shared/ansible.sh

View File

@ -1,39 +0,0 @@
---
- name: restart service
become: true
become_user: root
ansible.builtin.systemd: name={{ item }} daemon_reload=yes state=restarted enabled=yes
with_items:
- "{{ service_name }}"
tags:
- always
- name: restart background
become: true
become_user: root
ansible.builtin.systemd: name={{ item }} daemon_reload=yes state=restarted enabled=yes
with_items:
- "{{ background_service_name }}"
tags:
- always
- name: reload service
become: true
become_user: root
ansible.builtin.systemd: name={{ item }} daemon_reload=yes state=reloaded enabled=yes
with_items:
- "{{ service_name }}"
tags:
- always
- name: test nginx
become: true
become_user: root
ansible.builtin.command: nginx -t
- name: reload nginx
become: true
become_user: root
ansible.builtin.systemd: name=nginx state=reloaded enabled=yes
tags:
- always

View File

@ -1,16 +0,0 @@
---
- name: Adding {{ mailto }} to /etc/aliases
ansible.builtin.lineinfile:
path: /etc/aliases
regexp: "^{{ user }}: "
line: "{{ user }}: {{ mailto }}"
state: present
backup: true
tags:
- aliases
- name: Calling newaliases
ansible.builtin.command: newaliases
changed_when: false
tags:
- aliases

View File

@ -1,23 +0,0 @@
---
- name: Enabling forwarding of journal to syslog
become: true
ansible.builtin.lineinfile:
path: /etc/systemd/journald.conf
regexp: ForwardToSyslog
line: ForwardToSyslog=yes
state: present
backup: true
tags:
- journald
- syslog
- name: Restarting journald
become: true
ansible.builtin.systemd:
name: systemd-journald
daemon_reload: true
state: restarted
enabled: true
tags:
- journald
- syslog

View File

@ -1,11 +0,0 @@
---
- name: Coming up next
ansible.builtin.debug:
msg: Deploying branch {{ branch }} of {{ source_url }} to {{ env }} ({{ domain }})
tags:
- debug
- name: Installing required packages
ansible.builtin.apt: name={{ item }} state=present
with_items:
# acl is required to avoid "Failed to set permissions on the temporary files"
- acl

View File

@ -1,14 +0,0 @@
---
- name: "Writing {{ conf_d }}{{ conf_f }}"
vars:
conf_d: /etc/nginx/conf.d/
conf_f: log-format-upstreaminfo.conf
ansible.builtin.template:
src: "templates/nginx/conf.d/{{ conf_f }}"
dest: "{{ conf_d }}{{ conf_f }}"
backup: true
mode: 0664
tags:
- nginx
- config
- log_format

View File

@ -1,11 +0,0 @@
---
- name: Disabling nginx server tokens
ansible.builtin.lineinfile:
path: "{{ nginx_conf_dir }}/nginx.conf"
regexp: "server_tokens "
line: "\tserver_tokens off;" # noqa: no-tabs
state: present
backup: true
tags:
- nginx
- server_tokens

View File

@ -1,7 +0,0 @@
---
- name: Enabling site configuration {{ config_file }}
ansible.builtin.file:
dest: "{{ nginx_conf_dir }}/sites-enabled/{{ config_file }}"
src: "{{ nginx_conf_dir }}/sites-available/{{ config_file }}"
state: link
force: true

View File

@ -1,39 +0,0 @@
---
- name: Installing required packages
ansible.builtin.apt: name={{ item }} state=present
with_items:
- certbot
- git
- nginx
- python3-certbot-nginx
tags:
- nginx
- certbot
- name: Registering certbot
ansible.builtin.shell: >
certbot -n register --agree-tos --email {{ certbot.email }} &&
touch /etc/letsencrypt/.registered-{{ certbot.email }}
args:
creates: /etc/letsencrypt/.registered-{{ certbot.email }}
tags:
- nginx
- certbot
- register
- name: Getting a certificate for {{ domain }}
changed_when: true
ansible.builtin.command: >
certbot -n --nginx -d {{ domain }} --redirect
# N.B.: in certain cases certbot ignores systemd nginx
# (see https://github.com/certbot/certbot/issues/5486),
# which can be fixed with addition of the following hooks, but these hooks, in turn,
# produce a noticeable downtime for all services, leaving previous uwsgi connections
# producing "Connection reset"s until upstream uwsgi is restarted.
# --pre-hook "nginx -t && systemctl stop nginx"
# --post-hook "nginx -t && killall nginx; systemctl start nginx"
ignore_errors: false
tags:
- nginx
- certbot
- issue

View File

@ -1,13 +0,0 @@
---
- name: Remove the default site
ansible.builtin.file:
path: "{{ nginx_conf_dir }}/sites-enabled/default"
state: absent
- name: Remove the default site
ansible.builtin.file:
path: "{{ nginx_conf_dir }}/sites-enabled/default.conf"
state: absent
- name: Remove the default configuration
ansible.builtin.file:
path: "{{ nginx_conf_dir }}/conf.d/default.conf"
state: absent

View File

@ -1,25 +0,0 @@
---
- hosts: http
gather_facts: true
become: true
become_user: "{{ user }}"
roles: [common]
vars:
playbook_type: deploy
lock_file_path: /tmp/deploy-{{project_slug}}.lock
tasks:
- import_tasks: tasks/pull.yaml
- import_tasks: tasks/deploy.yaml
pre_tasks:
- stat: path={{lock_file_path}}
register: lock_file
- fail: msg="Another deploy was already started by {{lock_file.stat.pw_name}} {{((ansible_date_time.epoch|float - lock_file.stat.mtime) / 60)|int}}min ago.\nAdd '-e override_lock=true' to override if the deploy was abandoned."
when: lock_file.stat.exists|bool and override_lock is undefined
- copy: dest={{lock_file_path}} content="{{ansible_user_id}} locked at {{now(fmt='%Y-%m-%d %H:%M:%S')}}"
post_tasks:
- file: path={{lock_file_path}} state=absent

View File

@ -1,21 +0,0 @@
---
- hosts: localhost
connection: local
gather_facts: false
tasks:
- name: Creating download directory
ansible.builtin.file:
path: "{{ maxminddb_download_path }}"
mode: u+rwx
state: directory
register: ttt
tags:
- maxminddb
- name: Downloading and extracting MaxMind database from {{ maxminddb_url }}
ansible.builtin.unarchive:
src: "{{ maxminddb_url }}?suffix=tar.gz&license_key={{ maxminddb_license_key }}&edition_id={{ maxminddb_edition }}"
dest: "{{ maxminddb_download_path }}"
remote_src: true
tags:
- maxminddb

View File

@ -1 +1,10 @@
env: production
domain: studio.blender.org
host: web-production-1.hz-nbg1.blender.internal
allowed_hosts: "{{ domain }}"
db_host: db-postgres-production-1.hz-nbg1.blender.internal
ssl_only: true
admins: 'Anna Sirota: anna@blender.org'

View File

@ -1,8 +1,8 @@
---
http:
ingress:
hosts:
web-studio:
lb-production-1.hz-nbg1.blender.internal:
https:
application:
hosts:
sintel.blender.org:
web-production-1.hz-nbg1.blender.internal:

View File

@ -1,77 +0,0 @@
---
- import_playbook: download_maxmind_db.yaml
- hosts: http
gather_facts: false
become: true
roles: [common]
tasks:
- name: Installing required packages
ansible.builtin.apt: name={{ item }} state=present
with_items:
- git
- libjpeg-dev
- libpq-dev
- libxml2-dev
- libxslt-dev
- nginx
- postfix # to be able to configure /etc/aliases for cron
- postgresql-client
- python3
- python3-pip
- python3-virtualenv
- uwsgi
- uwsgi-plugin-python3
- vim
- zlib1g
- zlib1g-dev
- name: Creating user "{{ user }}:{{ group }}"
ansible.builtin.user:
name: "{{ user }}"
group: "{{ group }}"
- import_tasks: common/tasks/add_alias.yaml
# In systemd coming with Ubuntu 22.04 Standard{Output,Error}=syslog is no longer supported
# however, we still need it because log aggregation relies on syslog, not journald.
# This is why ForwardToSyslog is enabled system-wide.
- import_tasks: common/tasks/journald/forward_to_syslog.yaml
- name: Creating various directories
ansible.builtin.file: path={{ item }} state=directory owner={{ user }} group={{ group }} recurse=yes
with_items:
- "{{ dir.errors }}"
- "{{ dir.config }}"
- "{{ dir.media }}"
- "{{ dir.source }}"
- "{{ dir.pipeline_docs }}"
- import_tasks: tasks/pull.yaml
- name: Creating {{ env_file }}
ansible.builtin.template:
src: templates/dotenv
dest: "{{ env_file }}"
mode: 0644
backup: true
tags:
- dotenv
- import_tasks: tasks/copy_maxmind_db.yaml
- import_tasks: tasks/configure_uwsgi.yaml
- import_tasks: tasks/deploy.yaml
become: true
become_user: "{{ user }}"
- import_tasks: tasks/configure_nginx.yaml
tags:
- nginx
- import_tasks: tasks/setup_other_services.yaml
tags:
- services
- import_playbook: install_meilisearch.yaml

View File

@ -11,7 +11,7 @@
tags:
- meilisearch
- hosts: http
- hosts: application
gather_facts: false
become: true
tasks:

View File

@ -1,32 +0,0 @@
ansible==5.2.0
ansible-compat==2.1.0
ansible-core==2.12.1
ansible-lint==6.2.2
attrs==21.4.0
bracex==2.3.post1
cffi==1.15.0
commonmark==0.9.1
cryptography==36.0.1
enrich==1.2.7
iniconfig==1.1.1
Jinja2==3.0.3
jsonschema==4.6.0
MarkupSafe==2.0.1
packaging==21.3
pathspec==0.9.0
pluggy==1.0.0
py==1.11.0
pycparser==2.21
Pygments==2.12.0
pyparsing==3.0.6
pyrsistent==0.18.1
pytest==7.1.2
PyYAML==6.0
resolvelib==0.5.4
rich==12.4.4
ruamel.yaml==0.17.21
ruamel.yaml.clib==0.2.6
subprocess-tee==0.3.5
tomli==2.0.1
wcmatch==8.4
yamllint==1.26.3

View File

@ -1,42 +0,0 @@
---
- hosts: https
gather_facts: false
become: true
roles: [common]
tasks:
- name: Creating {{ nginx_temp_path }}
ansible.builtin.file:
path: "{{ nginx_temp_path }}"
state: directory
owner: "{{ nginx.user }}"
group: "{{ nginx.group }}"
recurse: true
when: nginx_temp_path is defined
- name: Creating errors directory
ansible.builtin.file:
path: "{{ dir.errors }}"
state: directory
owner: "{{ nginx.user }}"
group: "{{ nginx.group }}"
recurse: true
tags:
- error-pages
- import_tasks: tasks/copy_error_pages.yaml
- import_tasks: common/tasks/nginx/add_upstreaminfo_log_format.yaml
- name: Copying nginx config
ansible.builtin.template:
src: templates/nginx/https.conf
dest: "{{ nginx_conf_dir }}/sites-available/{{ service_name }}.conf"
mode: 0644
- import_tasks: common/tasks/nginx/enable_site.yaml
vars:
config_file: "{{ service_name }}.conf"
- import_tasks: common/tasks/nginx/get_certificate.yaml
notify:
- test nginx

1
playbooks/shared Submodule

@ -0,0 +1 @@
Subproject commit 2380780e143c20a77852bdd8d92da3a274726210

View File

@ -1,27 +0,0 @@
---
- import_tasks: common/tasks/nginx/remove_default_site.yaml
- import_tasks: common/tasks/nginx/disable_server_tokens.yaml
- name: Copying nginx config snippets
ansible.builtin.template:
src: "{{ item }}"
dest: "{{ dir.config }}/{{ item|basename }}"
mode: 0644
backup: true
with_fileglob:
- ../templates/nginx/snippets/*
- name: Copying nginx configs
ansible.builtin.template:
src: templates/nginx/http.conf
dest: "{{ nginx_conf_dir }}/sites-available/{{ service_name }}.conf"
mode: 0644
backup: true
register: nginx_config
- import_tasks: common/tasks/nginx/enable_site.yaml
vars:
config_file: "{{ service_name }}.conf"
notify:
- test nginx
- reload nginx

View File

@ -1,14 +0,0 @@
---
- name: Copying uWSGI config files
ansible.builtin.template:
src: "{{ item.src }}"
dest: "{{ item.dest }}"
mode: 0644
backup: true
loop:
- { src: templates/uwsgi/uwsgi.ini, dest: "/etc/uwsgi/{{ service_name }}.ini" }
- { src: templates/uwsgi/uwsgi.service, dest: "/etc/systemd/system/{{ service_name }}.service" }
notify:
- restart service
tags:
- uwsgi

View File

@ -1,24 +0,0 @@
---
- name: Copying error pages
ansible.builtin.template:
src: templates/nginx/errors/{{ item.src }}
dest: "{{ dir.errors }}/{{ item.dest }}"
mode: 0644
loop:
- src: 4xx.html
dest: 401.html
title: Unauthorized
- src: 4xx.html
dest: 403.html
title: Forbidden
- src: 4xx.html
dest: 404.html
title: Page Not Found
message: The page you requested does not exist
- src: 4xx.html
dest: 405.html
title: Method Not Allowed
- src: 5xx.html
dest: 5xx.html
tags:
- error-pages

View File

@ -1,12 +0,0 @@
---
- name: Copying MaxMind DB
ansible.builtin.copy:
src: "{{ item.src }}"
dest: "{{ dir.source }}"
owner: "{{ nginx.user }}"
group: "{{ group }}"
mode: u+rw,g+r
with_community.general.filetree: "{{ maxminddb_download_path }}"
when: '"GeoLite2-Country.mmdb" in item.get("src", "")'
tags:
- maxminddb

View File

@ -1,71 +0,0 @@
---
- import_tasks: copy_error_pages.yaml
become: true
become_user: "{{ user }}"
- name: Deleting {{ dir.source }}/.venv if it exists
become: true
become_user: "{{ user }}"
ansible.builtin.file:
path: "{{ dir.source }}/.venv"
state: absent
when: delete_venv
tags:
- pip
- name: Install Python requirements in {{ dir.source }}/.venv
become: true
become_user: "{{ user }}"
ansible.builtin.pip:
requirements: "{{ dir.source }}/requirements_prod.txt"
virtualenv: "{{ dir.source }}/.venv"
virtualenv_command: python{{ python_version }} -m venv
chdir: "{{ dir.source }}"
tags:
- pip
- name: Preparing to run database migrations
become: true
become_user: "{{ user }}"
tags:
- migrate
block:
- name: Displaying database migrations
import_tasks: managepy.yaml
vars:
command: migrate --plan
- name: Displaying database migrations
ansible.builtin.debug:
var: management_command_output.stdout
- name: Confirming database migrations
when:
- management_command_output.stdout | length > 0
- ('No planned migration operations' not in management_command_output.stdout)
ansible.builtin.pause:
prompt: Press return to continue. Press Ctrl+c and then "a" to abort.
- name: Running database migrations
when:
- management_command_output.stdout | length > 0
- ('No planned migration operations' not in management_command_output.stdout)
import_tasks: managepy.yaml
vars:
command: migrate
changed_when:
- management_command_output.stdout | length > 0
- ('No planned migration operations' not in management_command_output.stdout)
- import_tasks: managepy.yaml
become: true
become_user: "{{ user }}"
vars:
command: collectstatic --noinput
changed_when: true
tags:
- collectstatic
notify:
- reload service
- restart background
- test nginx
- reload nginx

View File

@ -1,8 +0,0 @@
---
- name: Running Django management command "{{ command }}"
ansible.builtin.shell:
cmd: >
bash -ca 'source {{ env_file }} &&
{{ dir.source }}/.venv/bin/python {{ dir.source }}/manage.py {{ command }}'
chdir: "{{ dir.source }}"
register: management_command_output

View File

@ -1,11 +0,0 @@
---
- name: Pulling latest branch "{{ branch }}"
become: true
become_user: "{{ user }}"
ansible.builtin.git:
repo: "{{ source_url }}"
dest: "{{ dir.source }}"
accept_hostkey: true
version: "{{ branch }}"
tags:
- git

View File

@ -1,29 +0,0 @@
---
- name: Copying systemd services
ansible.builtin.template:
src: "{{ item }}"
dest: /etc/systemd/system/{{ service_name }}-{{ item|basename }}
mode: 0644
backup: true
with_fileglob:
- ../templates/other-services/*.service
- name: Copying systemd timers
ansible.builtin.template:
src: "{{ item }}"
dest: /etc/systemd/system/{{ service_name }}-{{ item|basename }}
mode: 0644
backup: true
with_fileglob:
- ../templates/other-services/*.timer
- name: Enabling systemd timers
ansible.builtin.systemd:
name: "{{ service_name }}-{{ item|basename }}"
state: restarted
enabled: true
daemon_reload: true
tags:
- timers
with_fileglob:
- ../templates/other-services/*.timer

View File

@ -1,3 +0,0 @@
[virtualenvs]
in-project = true
create = false

View File

@ -0,0 +1,46 @@
{% extends "templates/nginx/base_application.conf" %}
{% block extra_locations %}
rewrite ^/spring /films/spring permanent;
rewrite ^/sprite-fright /films/sprite-fright permanent;
rewrite ^/charge$ /films/charge permanent;
rewrite ^/films/heist/(.*)$ /films/charge/$1 permanent;
rewrite ^/wing-it /films/wing-it permanent;
rewrite ^/films/pet-projects/(.*)$ /films/wing-it/$1 permanent;
location /robots.txt {
add_header Content-Type text/plain;
return 200 "";
}
location /favicon.ico {
root {{ dir.static }}/common/images/favicon;
}
# 2024-02-01 (anna): someone is hotlinking old static, this will stop requests from hammering the app
location /static-studio/ {
add_header Cache-Control "public,max-age=31536000";
return 200;
}
charset utf-8;
proxy_connect_timeout {{ proxy_timeout }};
proxy_send_timeout {{ proxy_timeout }};
proxy_read_timeout {{ proxy_timeout }};
send_timeout {{ proxy_timeout }};
keepalive_requests 1000;
keepalive_timeout {{ keepalive_timeout }};
# The Pipelines and Tools documentation (output of the blender-studio-pipeline repo)
location /pipeline {
alias {{ dir.pipeline_docs }};
index index.html;
try_files $uri.html $uri $uri/ =404;
}
# Meilisearch
location {{ meilisearch_endpoint }} {
rewrite {{ meilisearch_endpoint }}(.*) /$1 break;
proxy_pass http://{{ meilisearch_host }};
}
{% endblock extra_locations %}

View File

@ -1,5 +0,0 @@
log_format upstreaminfo
'$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" "$http_user_agent" '
'$request_length $request_time [upstream-{{ env }}-service-1234] [upstream-{{ env }}-service-1234] $upstream_addr '
'$upstream_response_length $upstream_response_time $upstream_status $request_id';

File diff suppressed because one or more lines are too long

View File

@ -1,76 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="refresh" content="5">
<title>{{ project_name }} &mdash; blender.org</title>
<style type='text/css'>
html, body {
background-color: #f8f8f8;
color: #4d4e53;
display: flex;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
height: 100%;
margin: 0;
width: 100%;
}
section {
width: 600px;
margin: auto;
}
article {
background-color: #fff;
box-shadow: rgba(0, 0, 0, 0.25) 0px 1px 4px -1px;
border-radius: 3px;
padding: 30px;
text-align: center;
}
h1 {
background: linear-gradient(to right, #0cc, violet);
background-clip: text;
-webkit-bg-clip: text;
-webkit-text-fill-color: transparent;
font-weight: 300;
font-size: 1.6em;
margin: 0 auto 15px;
}
p {
color: #78b13f;
padding: 10px 0;
}
small {
border-top: thin solid rgba(0,0,0,0.1);
color: #9E9FA2;
display: block;
padding-top: 15px;
}
.check {
border-bottom: 3px solid #78b13f;
border-right: 3px solid #78b13f;
display: inline-block;
height: 10px;
margin-right: 10px;
transform: rotate(45deg) translateY(-2px);
width: 5px;
}
</style>
</head>
<body>
<section>
<article>
<h1>{{ project_name }}</h1>
<p><span class="check"></span> We're updating the server, it should be back within a minute.</p>
<small>This page automatically refreshes and tries again for you.</small>
</article>
</section>
</body>
</html>

View File

@ -1,75 +0,0 @@
# uWSGI connection to the Studio Django app.
upstream studio_{{ env }}_app {
server {{ uwsgi_socket }};
}
server {
server_name {{ domain }};
set_real_ip_from 0.0.0.0/0;
set_real_ip_from ::/0;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
if ($host !~* ^({{ domain }})$) {
return 444;
}
access_log /var/log/nginx/{{ domain }}-access.log;
error_log /var/log/nginx/{{ domain }}-error.log;
rewrite ^/spring /films/spring permanent;
rewrite ^/sprite-fright /films/sprite-fright permanent;
rewrite ^/charge$ /films/charge permanent;
rewrite ^/films/heist/(.*)$ /films/charge/$1 permanent;
rewrite ^/wing-it /films/wing-it permanent;
rewrite ^/films/pet-projects/(.*)$ /films/wing-it/$1 permanent;
location /robots.txt {
add_header Content-Type text/plain;
return 200 "";
}
location /favicon.ico {
root {{ dir.static }}/common/images/favicon;
}
include {{ dir.config }}/studio.common.conf;
location / {
uwsgi_pass studio_{{ env }}_app;
# FIXME(anna): move to uwsgi_params after LB proxying is no more.
uwsgi_param HOST $host;
include {{ dir.config }}/studio_uwsgi_params;
}
}
# Proxied server config. Assumes the TLS termination is done upstream.
server {
listen 80;
server_name 10.129.22.239;
server_name cloudbalance.blender.org;
server_name cloud.blender.org;
include {{ dir.config }}/studio.common.conf;
# Only allow HTTP traffic from our private network.
allow 10.129.0.0/16;
deny all;
location / {
uwsgi_pass studio_{{ env }}_app;
include {{ dir.config }}/studio_uwsgi_params;
# Override some parameters to let uWSGI know the client is
# using HTTPS.
uwsgi_param REQUEST_SCHEME https;
uwsgi_param HTTPS true;
# Parameters that are not in the default studio_uwsgi_params,
# but are documented on https://uwsgi-docs.readthedocs.io/en/latest/Vars.html
uwsgi_param UWSGI_SCHEME https;
}
}

View File

@ -1,90 +0,0 @@
# This will be updated by certbot.
# N.B.: map always sets global variables visible across the server blocks.
# Use unique variable name unless expecting map results to be shared across all servers.
map $remote_addr ${{ nginx_var_prefix }}_allowed {
default 0;
127.0.0.1 1; # For local connections
{% if allowed_ips is defined %}{% for ip in allowed_ips %}
{{ ip }} 1;
{% endfor %}{% endif %}
}
server {
server_name {{ domain }};
# Avoid DisallowedHost "Invalid HTTP_HOST header" errors
if ($host !~* ^({{ domain }})$) {
return 444;
}
{% if env != 'production' %}{% for ip in allowed_ips %}
allow {{ ip }};
{% endfor %}
deny all;
{% endif %}
access_log /var/log/nginx/studio.blender.org-access.log upstreaminfo_forwarded_for;
error_log /var/log/nginx/{{ domain }}-error.log;
#location /robots.txt {
# add_header Content-Type text/plain;
# return 200 "User-agent: *\nDisallow: /\n";
#}
client_max_body_size {{ client_max_body_size }};
{% if nginx_temp_path is defined %}
client_body_temp_path {{ nginx_temp_path }} 1 2;
proxy_temp_path {{ nginx_temp_path }} 1 2;
{% endif %}
location / {
set $maintenance 0;
if (-f {{ dir.errors }}/maintenance_on) {
set $maintenance 1;
}
if (${{ nginx_var_prefix }}_allowed != 1) {
set $maintenance "${maintenance}1";
}
if ($maintenance = 11) {
return 503;
}
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://{{ host }}/;
proxy_connect_timeout 600s;
proxy_read_timeout 600s;
proxy_send_timeout 600s;
proxy_ssl_name $host;
proxy_ssl_server_name on;
send_timeout 600s;
}
error_page 403 /errors/403.html;
error_page 404 /errors/404.html;
error_page 405 /errors/405.html;
error_page 500 501 502 503 504 /errors/5xx.html;
location ^~ /errors/ {
alias {{ dir.errors | regex_replace('\\/*$', '/') }};
internal;
}
# Some other security related headers
# NOTE: Avoiding use of includeSubdomains in case "blender.org" alias causes it
# to match every subdomain of blender.org, at least until we are ready -- Dan
add_header Strict-Transport-Security "max-age=63072000; preload";
add_header X-Frame-Options "SAMEORIGIN";
add_header X-Xss-Protection "1; mode=block";
add_header X-Content-Type-Options "nosniff";
{% if "127.0.0.1" in host %}
location /media/ {
# must end with a slash in order to work
alias {{ dir.media | regex_replace('\\/*$', '/') }};
}
location /static/ {
# must end with a slash in order to work
alias {{ dir.static | regex_replace('\\/*$', '/') }};
}
{% endif %}
}

View File

@ -0,0 +1 @@
{% extends "templates/nginx/base_ingress.conf" %}

View File

@ -1,43 +0,0 @@
# This file is intended to be included in a server{} block.
# It contains the configuration that's common to both the name-based
# (public; this server does HTTPS) and IP-based (behind proxy; this
# server assumes the proxy does HTTPS) server blocks.
charset utf-8;
client_max_body_size {{ client_max_body_size }};
proxy_connect_timeout 600;
proxy_send_timeout 600;
proxy_read_timeout 600;
send_timeout 600;
keepalive_requests 1000;
keepalive_timeout 600s;
error_page 403 /errors/403.html;
error_page 404 /errors/404.html;
error_page 405 /errors/405.html;
error_page 500 501 502 503 504 /errors/5xx.html;
location ^~ /errors/ {
alias {{ dir.errors | regex_replace('\\/*$', '/') }};
internal;
}
# Django media, unused as we use S3 buckets for the media.
location {{ media_url }} {
alias {{ dir.media | regex_replace('\\/*$', '/') }};
}
location {{ static_url }} {
alias {{ dir.static | regex_replace('\\/*$', '/') }};
}
# The Pipelines and Tools documentation (output of the blender-studio-pipeline repo)
location /pipeline {
alias {{ dir.pipeline_docs }};
index index.html;
try_files $uri.html $uri $uri/ =404;
}
# Meilisearch
location {{ meilisearch_endpoint }} {
rewrite {{ meilisearch_endpoint }}(.*) /$1 break;
proxy_pass http://{{ meilisearch_host }};
}

View File

@ -1,16 +0,0 @@
uwsgi_param QUERY_STRING $query_string;
uwsgi_param REQUEST_METHOD $request_method;
uwsgi_param CONTENT_TYPE $content_type;
uwsgi_param CONTENT_LENGTH $content_length;
uwsgi_param REQUEST_URI $request_uri;
uwsgi_param PATH_INFO $document_uri;
uwsgi_param DOCUMENT_ROOT $document_root;
uwsgi_param SERVER_PROTOCOL $server_protocol;
uwsgi_param REQUEST_SCHEME $scheme;
uwsgi_param HTTPS $https if_not_empty;
uwsgi_param REMOTE_ADDR $remote_addr;
uwsgi_param REMOTE_PORT $remote_port;
uwsgi_param SERVER_PORT $server_port;
uwsgi_param SERVER_NAME $server_name;

View File

@ -1,6 +0,0 @@
[Unit]
Description=restart {{ project_name }} background worker
[Service]
Type=oneshot
ExecStart=/bin/systemctl restart {{ background_service_name }}.service

View File

@ -1,6 +0,0 @@
[Timer]
OnActiveSec=48h
OnUnitActiveSec=48h
[Install]
WantedBy=timers.target

View File

@ -1,18 +0,0 @@
[Unit]
Description={{ project_name }} background worker
After=syslog.target
[Service]
User={{ user }}
Group={{ group }}
EnvironmentFile={{ env_file }}
ExecStart={{ dir.source }}/.venv/bin/python {{ dir.source }}/manage.py process_tasks
ExecStop=kill -s SIGTSTP $MAINPID
Restart=always
Type=idle
SyslogIdentifier={{ service_name }}
NotifyAccess=all
[Install]
WantedBy=multi-user.target

View File

@ -1,9 +0,0 @@
[Unit]
Description=Clear expired {{ project_name }} sessions
[Service]
Type=oneshot
User={{ user }}
Group={{ group }}
ExecStart={{ dir.source }}/.venv/bin/python {{ dir.source }}/manage.py clearsessions
SyslogIdentifier={{ service_name }}

View File

@ -1,7 +0,0 @@
[Timer]
OnCalendar=daily
RandomizedDelaySec=5h
AccuracySec=1us
[Install]
WantedBy=timers.target

View File

@ -1,10 +0,0 @@
[Unit]
Description=Deleted completed tasks of {{ project_name }}
[Service]
Type=oneshot
User={{ user }}
Group={{ group }}
EnvironmentFile={{ env_file }}
ExecStart={{ dir.source }}/.venv/bin/python {{ dir.source }}/manage.py delete_completed_tasks
SyslogIdentifier={{ service_name }}

View File

@ -1,7 +0,0 @@
[Timer]
OnCalendar=daily
RandomizedDelaySec=5h
AccuracySec=1us
[Install]
WantedBy=timers.target

View File

@ -9,18 +9,22 @@ client_max_body_size: 5500M
python_version: "3.10"
delete_venv: false # set to true if venv has to be re-created from scratch
# Set to true if ingress == application:
# meaning that SSL is terminated by and Django app is run on the same host.
single_host: false
dir:
source: "/opt/{{ service_name }}"
static: "/var/www/{{ service_name }}/static"
media: "/var/www/{{ service_name }}/media"
errors: "/var/www/{{ service_name }}/html/errors"
config: /etc/nginx/snippets
pipeline_docs: "/var/www/blender-studio-pipeline-{{ env }}"
env_file: "{{ dir.source }}/.env"
uwsgi_pid: "{{ dir.source }}/{{ service_name }}.pid"
uwsgi_module: studio.wsgi
uwsgi_socket: "unix://{{ dir.source }}/studio.sock"
uwsgi_processes: 8
uwsgi_socket: "{{ dir.source }}/uwsgi.sock"
host: web-studio.internal
nginx:
@ -29,19 +33,26 @@ nginx:
nginx_conf_dir: /etc/nginx
# Studio workflows include heavy uploads, so client temp path must have plenty of disk space
nginx_temp_path: /data/nginx/tmp
# For prepending to variable names in cases when they have to be set outside server block,
# e.g. for use in a `map $something ... {}`.
nginx_var_prefix: "{{ service_name|regex_replace('-', '_') }}"
user: "studio-{{ env }}"
group: "{{ nginx.group }}"
rate_limit:
name: 'hundred_per_minute'
size: '10m'
rate: '100r/m'
burst: 50
delay: 10
keepalive_timeout: "600s"
mailto: cron@blender.org
aliases: null # This project doesn't use cron
certbot:
email: root@blender.org
source_url: https://projects.blender.org/studio/{{ project_slug }}.git
branch: production
ssl_only: false
ca_certificate: /usr/local/share/ca-certificates/cloud-init-ca-cert-1.crt
meilisearch_version: 0.25.2
meilisearch_user: meilisearch
meilisearch_group: "{{ group }}"
@ -53,14 +64,20 @@ meilisearch_database: "{{ meilisearch_home }}/data.ms"
meilisearch_bin: meilisearch-{{ meilisearch_version }}
meilisearch_bin_path: /usr/bin/{{ meilisearch_bin }}
maxminddb_edition: GeoLite2-Country
maxminddb_url: https://download.maxmind.com/app/geoip_download
maxminddb_path: /opt/maxmind
maxminddb_download_path: /tmp/maxmind
maxmind_license_key: 'SET-IN-VAULT'
maxmind:
edition: GeoLite2-Country
url: https://download.maxmind.com/app/geoip_download
path: /opt/maxmind
download_path: /tmp/maxmind
license_key: "{{ maxmind_license_key }}"
media_url: /media/
static_url: /static/
db_user: "studio_{{ env }}"
db_name: "studio_{{ env }}"
allowed_hosts: "{{ domain }},cloudbalance.blender.org,cloud.blender.org"
# The following variables should be encrypted with Ansible Vault
@ -69,4 +86,28 @@ allowed_hosts: "{{ domain }},cloudbalance.blender.org,cloud.blender.org"
# sentry_dsn:
# meili_master_key:
# maxminddb_license_key:
include_common_services:
- background
- background-restart
- clearsessions
- delete-completed-tasks
- notify-email@
# Override required packages list
packages_common:
- git
- libjpeg-dev
- libpq-dev
- libxml2-dev
- libxslt-dev
- nginx
- postgresql-client
- python3-pip
- python{{ python_version }}
- python{{ python_version }}-dev
- python{{ python_version }}-distutils
- python{{ python_version }}-venv
- vim
- zlib1g
- zlib1g-dev

View File

@ -81,7 +81,6 @@ pypdf==4.2.0
pypng==0.20220715.0
python-bidi==0.4.2
python-dateutil==2.8.2
python-dotenv==0.21.0
python-monkey-business==1.0.0
python-stdnum==1.18
pytz==2022.7.1

View File

@ -34,6 +34,7 @@ pycodestyle==2.7.0
pydocstyle==6.1.1
pyflakes==2.3.1
Pygments==2.13.0
python-dotenv==0.21.0
responses==0.25.3
snowballstemmer==2.2.0
tblib==3.0.0

View File

@ -1,7 +1,7 @@
<div class="input-group w-100 {% if sm %}input-group-sm{% endif %}" id="search-container">
<input autocapitalize="none" autocomplete="off" autocorrect="off" id="searchInput" class="flex-grow-1 form-control me-3" placeholder="Search tags and kewords" spellcheck="false" type="text">
<input autocapitalize="none" autocomplete="off" autocorrect="off" id="searchInput" class="flex-grow-1 form-control me-3" placeholder="Search tags or keywords..." spellcheck="false" type="text">
<div class="btn-row input-group-addon">
<button class="btn" id="clearSearchBtn" title="Cancel"><i class="i-cancel"></i></button>
<button class="btn btn-primary" id="searchBtn" type="submit"><i class="i-search"></i></button>
<button class="btn btn-link" id="clearSearchBtn" title="Cancel"><i class="i-cancel"></i></button>
</div>
</div>

View File

@ -9,7 +9,6 @@ import os
import pathlib
import sys
from dotenv import load_dotenv
import braintree
import dj_database_url
import meilisearch
@ -17,10 +16,14 @@ import meilisearch
import common.upload_paths
# Load variables from .env, if available
path = os.path.dirname(os.path.abspath(__file__)) + '/../.env'
if os.path.isfile(path):
load_dotenv(path)
try:
from dotenv import load_dotenv
# Load variables from .env, if available
path = os.path.dirname(os.path.abspath(__file__)) + '/../.env'
if os.path.isfile(path):
load_dotenv(path)
except ImportError: # This is expected: there should be no python-dotenv in production
pass
def _get(name: str, default=None, coerse_to=None):
@ -310,25 +313,32 @@ LOGGING = {
'formatters': {
'default': {'format': '%(asctime)-15s %(levelname)8s %(name)s %(message)s'},
'verbose': {
'format': '%(asctime)-15s %(levelname)8s %(name)s %(process)d %(thread)d %(message)s'
'format': (
'%(asctime)s %(levelname)8s [%(filename)s:%(lineno)d '
'%(funcName)s] %(name)s %(message)s '
),
},
},
'handlers': {
'console': {
'class': 'logging.StreamHandler',
'formatter': 'default', # Set to 'verbose' in production
'stream': 'ext://sys.stderr',
'formatter': 'verbose',
},
'mail_admins': {
'level': 'ERROR',
'class': 'django.utils.log.AdminEmailHandler',
'include_html': True,
},
},
'loggers': {
'asyncio': {'level': 'WARNING'},
'django': {'level': 'WARNING'},
'django': {'level': 'INFO'},
'urllib3': {'level': 'WARNING'},
'search': {'level': 'DEBUG'},
'static_assets': {'level': 'DEBUG'},
'looper': {'level': 'DEBUG'},
},
'root': {'level': 'WARNING', 'handlers': ['console']},
'root': {'level': 'INFO', 'handlers': ['console', 'mail_admins']},
}
SITE_ID = 1
@ -512,8 +522,14 @@ GATEWAYS = {
},
}
# Optional Sentry configuration
if os.environ.get('ADMINS') is not None:
# Expects the following format:
# ADMINS='J Doe: jane@example.com, John Dee: john@example.com'
ADMINS = [[_.strip() for _ in adm.split(':')] for adm in os.environ.get('ADMINS').split(',')]
EMAIL_SUBJECT_PREFIX = f'[{ALLOWED_HOSTS[0]}]'
SERVER_EMAIL = f'django@{ALLOWED_HOSTS[0]}'
# Optional Sentry configuration
SENTRY_DSN = _get('SENTRY_DSN')
if SENTRY_DSN:
import sentry_sdk

View File

@ -1,5 +1,5 @@
"""
WSGI config for training project.
WSGI config for Blender Studio project.
It exposes the WSGI callable as a module-level variable named ``application``.
@ -8,18 +8,9 @@ https://docs.djangoproject.com/en/2.2/howto/deployment/wsgi/
"""
import os
import os.path
import pathlib
from django.core.wsgi import get_wsgi_application
from dotenv import load_dotenv
BASE_DIR = pathlib.Path(__file__).absolute().parent.parent
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'studio.settings')
# Load variables from .env, if available
path = BASE_DIR / '.env'
if os.path.isfile(path):
load_dotenv(path)
application = get_wsgi_application()

View File

@ -125,13 +125,14 @@ class SectionAdmin(mixins.ViewOnSiteMixin, admin.ModelAdmin):
@admin.register(flatpages.TrainingFlatPage)
class TrainingFlatPageAdmin(mixins.ViewOnSiteMixin, admin.ModelAdmin):
save_on_top = True
autocomplete_fields = ['training', 'attachments']
list_display = ('title', 'training', 'view_link')
list_filter = [
'training',
]
prepopulated_fields = {'slug': ('slug',)}
raw_id_fields = ['training', 'attachments']
raw_id_fields = ['training']
@admin.register(progress.UserSectionProgress)

View File

@ -7,28 +7,22 @@
{% endblock %}
{% block content %}
{% block training_header_image %}
{% endblock training_header_image %}
<div class="container pt-2 pt-md-3">
<div class="container-fluid pt-2 pt-md-3">
<div class="d-md-none mb-3 pt-2 row">
<div class="col-12">
<button class="btn js-nav-drawer-btn-toggle"><i class="i-list"></i> Content</button>
</div>
</div>
<div class="row">
<div class="col-lg-3 col-md-4 mb-3 fade-xs js-nav-drawer-helper nav-drawer-helper">
<div class="row training-group">
<div class="training-group-item training-group-item-nav fade-xs js-nav-drawer-helper nav-drawer-helper">
<nav class="nav-drawer-nested">
<div class="nav-drawer-body">
{% block nested_nav_drawer_inner %}
{% endblock nested_nav_drawer_inner %}
{% block nested_nav_drawer_inner %}{% endblock nested_nav_drawer_inner %}
</div>
</nav>
</div>
<div class="col col-lg-9 col-md-8">
{% block nexted_content %}
{% endblock nexted_content %}
</div>
{% block nexted_content %}{% endblock nexted_content %}
</div>
</div>
{% endblock content %}

View File

@ -66,9 +66,9 @@
{% block nested_nav_drawer_header %}
<div class="drawer-nav-header">
<p class="mb-1 text-muted">
{% firstof training.type.label training.type|capfirst %}
</p>
<h6 class="mb-1 text-muted fw-normal">
<small>{% firstof training.type.label training.type|capfirst %}</small>
</h6>
<a class="fw-bold" href="{{ navigation.overview_url }}">{{ training.name }}</a>
</div>
{% endblock nested_nav_drawer_header %}
@ -87,7 +87,7 @@
{% if chapter_navigation.is_published or request.user.is_superuser or request.user.is_staff %}
<div class="drawer-nav-dropdown-wrapper">
<a href="{{ chapter_navigation.url }}"
class="drawer-nav-dropdown dropdown fw-bold {% if chapter_navigation.current %} active{% endif %}"
class="drawer-nav-dropdown dropdown {% if chapter_navigation.current %} active{% endif %}"
data-bs-tooltip="tooltip-overflow" data-placement="top" title="{{ chapter_navigation.name }}">
<span class="drawer-nav-dropdown-text overflow-text">{{ chapter_navigation.name }}</span>
{% if not chapter_navigation.is_published %}

View File

@ -19,64 +19,70 @@
{% endblock %}
{% block nexted_content %}
{% if chapter.thumbnail %}
<div class="row mb-3">
<div class="col">
{% if section.is_free or request.user|has_active_subscription %}
{% firstof chapter.picture_header chapter.thumbnail as header %}
{% include "common/components/helpers/image_set.html" with alt=chapter.name classes="img-fluid img-width-100 rounded" img_source=header xsmall_width="600" small_width="800" medium_width="1000" large_width="1200" xlarge_width="1920" %}
{% else %}
{% include 'common/components/content_locked.html' with background=training.picture_header %}
{% endif %}
</div>
<div class="training-group-item-content-detail">
<div class="training-group-item-content-detail-inner">
{% if chapter.thumbnail %}
<div class="row mb-3">
<div class="col">
{% if section.is_free or request.user|has_active_subscription %}
{% firstof chapter.picture_header chapter.thumbnail as header %}
{% include "common/components/helpers/image_set.html" with alt=chapter.name classes="img-fluid img-width-100 rounded" img_source=header xsmall_width="600" small_width="800" medium_width="1000" large_width="1200" xlarge_width="1920" %}
{% else %}
{% include 'common/components/content_locked.html' with background=training.picture_header %}
{% endif %}
</div>
</div>
{% endif %}
<div class="align-items-start row">
<div class="col-12 col-md mb-3">
<div class="d-md-block d-none">
<p class="small text-muted">{{ training.name }}</p>
<h2 class="mb-0">{{ chapter.name }}</h2>
</div>
</div>
<div class="col-12 col-md-auto mb-2 mb-md-0 mt-0 mt-md-3">
<div class="button-toolbar-container">
<div class="button-toolbar">
{% if user.is_staff %}
<a href="{{ chapter.admin_url }}" class="btn btn-admin">
<i class="i-edit"></i>
<span>Edit</span>
</a>
{% endif %}
<button data-bs-toggle="dropdown" class="btn btn-link">
<i class="i-more-vertical"></i>
</button>
<div class="dropdown-menu dropdown-menu-end">
<a href="https://projects.blender.org/studio/blender-studio/issues/new" target="_blank" class="dropdown-item">
<i class="i-flag"></i>
<span>Report Problem</span>
</a>
</div>
</div>
</div>
</div>
</div>
{% if chapter.description %}
<section class="mb-3 row">
<div class="col">
<div class="markdown-text">
{% with_shortcodes chapter.description|markdown %}
</div>
</div>
</section>
{% endif %}
</div>
{% endif %}
<div class="mb-3 row ">
<div class="col">
<div class="align-items-start row">
<div class="col-12 col-md mb-3">
<div class="d-md-block d-none">
<p class="small text-muted">{{ training.name }}</p>
<h2 class="mb-0">{{ chapter.name }}</h2>
</div>
</div>
<div class="col-12 col-md-auto mb-2 mb-md-0 mt-0 mt-md-3">
<div class="button-toolbar-container">
<div class="button-toolbar">
{% if user.is_staff %}
<a href="{{ chapter.admin_url }}" class="btn btn-admin">
<i class="i-edit"></i>
<span>Edit</span>
</a>
{% endif %}
<button data-bs-toggle="dropdown" class="btn btn-link">
<i class="i-more-vertical"></i>
</button>
<div class="dropdown-menu dropdown-menu-end">
<a href="https://projects.blender.org/studio/blender-studio/issues/new" target="_blank" class="dropdown-item">
<i class="i-flag"></i>
<span>Report Problem</span>
</a>
</div>
</div>
</div>
</div>
</div>
{% if chapter.description %}
<section class="markdown-text mb-3">{% with_shortcodes chapter.description|markdown %}</section>
{% endif %}
<div class="row">
<div class="col-12">
<div class="cards card-layout-card-transparent files">
{% for section in chapter.sections.all %}
{% if section.is_published %}
{% include "common/components/file_section.html" with section=section %}
{% endif %}
{% endfor %}
</div>
<div class="mb-3 row">
<div class="col-12">
<div class="cards card-layout-card-transparent files">
{% for section in chapter.sections.all %}
{% if section.is_published %}
{% include "common/components/file_section.html" with section=section %}
{% endif %}
{% endfor %}
</div>
</div>
</div>

View File

@ -19,45 +19,35 @@
{% endblock %}
{% block nexted_content %}
<div class="row mb-3">
<div class="training-group-item training-group-item-video">
{% if section.preview_youtube_link %}
<div class="col">
<div class="overflow-hidden rounded">
{% include 'common/components/video_player_embed.html' with url=section.preview_youtube_link rounded=True %}
</div>
</div>
{% include 'common/components/video_player_embed.html' with url=section.preview_youtube_link rounded=True %}
{% elif video %}
<div class="col">
<div class="overflow-hidden rounded">
{% if section.is_free or request.user|has_active_subscription %}
{% if user.is_anonymous %}
{% include 'common/components/video_player.html' with url=video.source.url poster=section.thumbnail_m_url tracks=section.static_asset.video.tracks.all loop=section.static_asset.video.loop %}
{% else %}
{% include 'common/components/video_player.html' with url=video.source.url progress_url=video.progress_url start_position=video.start_position poster=section.thumbnail_m_url tracks=section.static_asset.video.tracks.all loop=section.static_asset.video.loop %}
{% endif %}
{% else %}
{% include 'common/components/content_locked.html' with background=section.thumbnail_m_url %}
{% endif %}
</div>
</div>
{% if section.is_free or request.user|has_active_subscription %}
{% if user.is_anonymous %}
{% include 'common/components/video_player.html' with url=video.source.url poster=section.thumbnail_m_url tracks=section.static_asset.video.tracks.all loop=section.static_asset.video.loop %}
{% else %}
{% include 'common/components/video_player.html' with url=video.source.url progress_url=video.progress_url start_position=video.start_position poster=section.thumbnail_m_url tracks=section.static_asset.video.tracks.all loop=section.static_asset.video.loop %}
{% endif %}
{% else %}
{% include 'common/components/content_locked.html' with background=section.thumbnail_m_url %}
{% endif %}
{% else %}
<div class="col">
<div class="overflow-hidden rounded">
{% if section.is_free or request.user|has_active_subscription %}
{% if section.thumbnail %}
{% include "common/components/helpers/image_set.html" with alt=section.name classes="img-fluid img-width-100 rounded" img_source=section.thumbnail xsmall_width="600" small_width="800" medium_width="1000" large_width="1200" xlarge_width="1920" %}
{% endif %}
{% else %}
{% include 'common/components/content_locked.html' with background=training.picture_header %}
{% endif %}
{% if section.is_free or request.user|has_active_subscription %}
{% if section.thumbnail %}
{% include "common/components/helpers/image_set.html" with alt=section.name classes="img-fluid img-width-100" img_source=section.thumbnail xsmall_width="600" small_width="800" medium_width="1000" large_width="1200" xlarge_width="1920" %}
{% endif %}
{% else %}
<div class="col">
{% include 'common/components/content_locked.html' with background=training.picture_header %}
</div>
</div>
{% endif %}
{% endif %}
</div>
<div class="row">
<div class="col">
<div class="align-items-start row">
<div class="col-12 col-md mb-2 mb-md-3">
<div class="training-group-item training-group-item-content">
<div class="box">
<div class="row">
<div class="col mb-2 mb-md-3">
<div class="d-md-block d-none">
<p class="small text-muted">{{ chapter.name }}</p>
<h2>{{ section.name }}</h2>
@ -92,7 +82,7 @@
<section class="mb-3 markdown-text">
{% with_shortcodes section.text|markdown_unsafe %}
</section>
<section class="mb-3">
<section>
{% include 'comments/components/comment_section.html' %}
</section>
</div>

View File

@ -11,24 +11,23 @@
{% javascript 'training' %}
{% endblock scripts %}
{% block training_header_image %}
{% if training.picture_header_url %}
<div class="container">
<div class="mt-3 row">
<div class="col">
<img src="{{ training.picture_header_url }}" class="img-fluid img-width-100 rounded" alt="{{ training.name }}">
</div>
</div>
</div>
{% endif %}
{% endblock training_header_image %}
{% block nexted_content %}
<section>
<div class="row">
<div class="col">
<div class="row align-items-start mb-2">
<div class="col-12 col-md">
<section class="training-group-item training-group-item-content-detail">
<div class="row training-group-item-content-detail-inner">
<div class="col">
{% if training.picture_header_url %}
<div class="mb-3 row">
<div class="col">
<div class="training-header-img-helper">
<img src="{{ training.picture_header_url }}" class="img-fluid img-width-100 rounded" alt="{{ training.name }}">
</div>
</div>
</div>
{% endif %}
<div class="align-items-start mb-3 row">
<div class="col-12 col-md">
<h1 class="mb-0">{{ training.name }}</h1>
</div>
<div class="col-12 col-md-auto">
@ -36,9 +35,6 @@
{% if training.is_free %}
{% include "common/components/cards/pill.html" with label='Free' %}
{% endif %}
{% for tag in training.tags_list %}
{% include 'common/components/cards/pill.html' with label=tag %}
{% endfor %}
</div>
<div class="button-toolbar justify-content-end">
{% if request.user.is_authenticated %}
@ -67,7 +63,24 @@
</div>
</div>
</div>
<section class="markdown-text mb-3">{% with_shortcodes training.summary_rendered %}</section>
{% if training.tags_list %}
<div class="mb-3 row">
<div class="col-12">
{% for tag in training.tags_list %}
{% include 'common/components/cards/pill.html' with label=tag %}
{% endfor %}
</div>
</div>
{% endif %}
<section class="row">
<div class="col-12">
<div class="markdown-text">
{% with_shortcodes training.summary_rendered %}
</div>
</div>
</section>
</div>
</div>
</section>

Some files were not shown because too many files have changed in this diff Show More