WIP: Attach invoice PDF to payment emails #104418
3
.gitmodules
vendored
@ -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
|
@ -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
|
||||
|
@ -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
|
||||
|
BIN
common/static/common/images/welcome/film-thumbnail-charge.webp
Normal file
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 72 KiB |
BIN
common/static/common/images/welcome/film-thumbnail-gold.webp
Normal file
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 68 KiB |
BIN
common/static/common/images/welcome/film-thumbnail-wing-it.webp
Normal file
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -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();
|
||||
|
@ -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
|
||||
|
@ -1,7 +1,7 @@
|
||||
/* Breadcrumb. */
|
||||
.breadcrumb-item
|
||||
&::before
|
||||
padding-top: var(--spacer-1)
|
||||
&::before
|
||||
padding-top: var(--spacer-1)
|
||||
|
||||
/* Button group. */
|
||||
.button-toolbar
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -8,6 +8,7 @@
|
||||
.bg-filter-blur
|
||||
background-color: transparent
|
||||
backdrop-filter: blur(24px)
|
||||
-webkit-backdrop-filter: blur(24px)
|
||||
position: relative
|
||||
|
||||
&::before
|
||||
|
@ -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"
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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" />
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
|
@ -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`:
|
||||
|
@ -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> ${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: {
|
||||
|
@ -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">
|
||||
|
@ -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 %}
|
||||
|
@ -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>
|
||||
|
@ -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 %}
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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 }}
|
||||
|
@ -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 %}
|
||||
|
@ -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">
|
||||
|
39
films/templates/films/productionlog_list.html
Normal 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 %}
|
@ -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(
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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
@ -0,0 +1 @@
|
||||
shared/ansible.cfg
|
@ -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
@ -0,0 +1 @@
|
||||
shared/ansible.sh
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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'
|
||||
|
@ -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:
|
||||
|
@ -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
|
@ -11,7 +11,7 @@
|
||||
tags:
|
||||
- meilisearch
|
||||
|
||||
- hosts: http
|
||||
- hosts: application
|
||||
gather_facts: false
|
||||
become: true
|
||||
tasks:
|
||||
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -1,3 +0,0 @@
|
||||
[virtualenvs]
|
||||
in-project = true
|
||||
create = false
|
46
playbooks/templates/nginx/application.conf
Normal 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 %}
|
@ -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';
|
@ -1,76 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="refresh" content="5">
|
||||
<title>{{ project_name }} — 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>
|
@ -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;
|
||||
}
|
||||
}
|
@ -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 %}
|
||||
}
|
1
playbooks/templates/nginx/ingress.conf
Normal file
@ -0,0 +1 @@
|
||||
{% extends "templates/nginx/base_ingress.conf" %}
|
@ -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 }};
|
||||
}
|
@ -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;
|
@ -1,6 +0,0 @@
|
||||
[Unit]
|
||||
Description=restart {{ project_name }} background worker
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/bin/systemctl restart {{ background_service_name }}.service
|
@ -1,6 +0,0 @@
|
||||
[Timer]
|
||||
OnActiveSec=48h
|
||||
OnUnitActiveSec=48h
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
@ -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
|
@ -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 }}
|
@ -1,7 +0,0 @@
|
||||
[Timer]
|
||||
OnCalendar=daily
|
||||
RandomizedDelaySec=5h
|
||||
AccuracySec=1us
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
@ -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 }}
|
@ -1,7 +0,0 @@
|
||||
[Timer]
|
||||
OnCalendar=daily
|
||||
RandomizedDelaySec=5h
|
||||
AccuracySec=1us
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
|
@ -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()
|
||||
|
@ -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)
|
||||
|
@ -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 %}
|
||||
|
@ -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 %}
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|