Implement Web Assets' theme system and selection, and add 'light' theme #118

Merged
Márton Lente merged 97 commits from martonlente/extensions-website:ui/theme-light into main 2024-05-08 14:20:07 +02:00
42 changed files with 565 additions and 180 deletions
Showing only changes of commit 5b65c0de92 - Show all commits

View File

@ -10,9 +10,13 @@ from django.http.request import HttpRequest
def extra_context(request: HttpRequest) -> Dict[str, str]: def extra_context(request: HttpRequest) -> Dict[str, str]:
"""Injects some configuration values into template context.""" """Injects some configuration values into template context."""
user_is_moderator = False
if request.user.is_authenticated:
user_is_moderator = request.user.is_moderator
return { return {
'BLENDER_ID': { 'BLENDER_ID': {
'BASE_URL': settings.BLENDER_ID['BASE_URL'], 'BASE_URL': settings.BLENDER_ID['BASE_URL'],
}, },
'canonical_url': request.build_absolute_uri(request.path), 'canonical_url': request.build_absolute_uri(request.path),
'user_is_moderator': user_is_moderator,
} }

View File

@ -7,28 +7,13 @@
"name": "Blender Extensions Dev" "name": "Blender Extensions Dev"
} }
}, },
{
"model": "flatpages.flatpage",
"pk": 1,
"fields": {
"url": "/conditions-of-use/",
"title": "Conditions of Use",
"content": "You may not use any of Blender online services to:\r\n\r\n * Do anything illegal or otherwise violate applicable law,\r\n\r\n * Threaten, harass, or violate the privacy rights of others; send unsolicited communications; or intercept, monitor, or modify communications not intended for you,\r\n\r\n * Harm users such as by using viruses, spyware or malware, worms, trojan horses, time bombs or any other such malicious codes or instructions,\r\n\r\n * Deceive, mislead, defraud, phish, or commit or attempt to commit identity theft,\r\n\r\n * Engage in or promote illegal gambling,\r\n\r\n * Degrade, intimidate, incite violence against, or encourage prejudicial action against someone or a group based on age, gender, race, ethnicity, national origin, religion, sexual orientation, disability, geographic location or other protected category,\r\n\r\n * Exploit or harm children,\r\n\r\n * Sell, purchase, or advertise illegal or controlled products or services,\r\n\r\n * Upload, download, transmit, display, or grant access to content that includes graphic depictions of sexuality or violence,\r\n\r\n * Collect or harvest personally identifiable information without permission. This includes, but is not limited to, account names and email addresses,\r\n\r\n * Engage in any activity that interferes with or disrupts Blenders online services or products (or the servers and networks which are connected to Blenders services),\r\n\r\n * Violate the copyright, trademark, patent, or other intellectual property rights of others,\r\n\r\n * Violate any persons rights of privacy or publicity.\r\n\r\nYou may not use any Blender online service in a way that violates the Conditions of Use or license that applies to the particular service.\r\n\r\nThese are only examples. You should not consider this a complete list, and we may update the list from time to time. Blender Institute reserves the right to remove any content or suspend any users that it deems in violation of these conditions.",
"enable_comments": false,
"template_name": "",
"registration_required": false,
"sites": [
1
]
}
},
{ {
"model": "flatpages.flatpage", "model": "flatpages.flatpage",
"pk": 2, "pk": 2,
"fields": { "fields": {
"url": "/policies/", "url": "/terms-of-service/",
"title": "Extensions Policies", "title": "Terms of Service",
"content": "# Free and Open Source\r\nUploaded extensions must be wholly compatible with [GNU General Public License, version 2](https://www.gnu.org/licenses/old-licenses/gpl-2.0.html) or [GNU General Public License, version 3](https://www.gnu.org/licenses/gpl-3.0.html).\r\n\r\n# No surprises\r\nAn extension must have an easy-to-read description mentioning everything it does.\r\n\r\nIt must be abundantly clear from the extensions listing what functionality it offers, and Blender users should not be presented with unexpected experiences when they download and install the extension.\r\n\r\n# Content\r\n\r\n* Extension listings must comply with Blender services [conditions of use](/conditions-of-use/).\r\n\r\n* Extensions offered on extensions.blender.org should be fully functional, documented and actively maintained products.\r\n\r\n* Extensions should not require any external functional components (other than Blender itself) that need to be downloaded from elsewhere.\r\n \r\n* Extensions that (need to) connect to Internet services outside of blender.org ecosystem, should offer access to these sites without additional restrictions (such as login or registration).\r\n\r\n* If the extension is a fork of another extension, the name must clearly distinguish it from the original and provide a significant difference in functionality and/or code.\r\n\r\n* Extensions that are intended for internal or private use or are only accessible to a closed user group may not be listed on extensions.blender.org.\r\n\r\n* Linking to external funding platforms in the description is allowed.\r\n\r\n However, it is not allowed to have additional requirements (such as registration, payments or keys) blocking functionality of the extension.\r\n\r\n# Acceptable code practices\r\n\r\n* Extension code must be reviewable: no obfuscated code or byte code is allowed.\r\n\r\n* Extension must be self-contained and not load remote code for execution.\r\n\r\n* Extension must not send data to any remote locations without authorization from the user.\r\n\r\n* Extension should avoid including redundant code or files.\r\n\r\n* Extension must not negatively impact performance or stability of Blender.\r\n\r\n* Add-on's code must be compliant with the [guidelines](https://developer.blender.org/docs/handbook/addons/guidelines/).", "content": "# Free and Open Source\r\nUploaded extensions must be wholly compatible with [GNU General Public License, version 2](https://www.gnu.org/licenses/old-licenses/gpl-2.0.html) or [GNU General Public License, version 3](https://www.gnu.org/licenses/gpl-3.0.html).\r\n\r\n# No surprises\r\nAn extension must have an easy-to-read description mentioning everything it does.\r\n\r\nIt must be abundantly clear from the extensions listing what functionality it offers, and Blender users should not be presented with unexpected experiences when they download and install the extension.\r\n\r\n# Content\r\n\r\n* Extension listings must not do anything illegal.\r\n\r\n* Extensions offered on extensions.blender.org should be fully functional, documented and actively maintained products.\r\n\r\n* Extensions should not require any external functional components (other than Blender itself) that need to be downloaded from elsewhere.\r\n \r\n* Extensions that (need to) connect to Internet services outside of blender.org ecosystem, should offer access to these sites without additional restrictions (such as login or registration).\r\n\r\n* If the extension is a fork of another extension, the name must clearly distinguish it from the original and provide a significant difference in functionality and/or code.\r\n\r\n* Extensions that are intended for internal or private use or are only accessible to a closed user group may not be listed on extensions.blender.org.\r\n\r\n* Linking to external funding platforms in the description is allowed.\r\n\r\n However, it is not allowed to have additional requirements (such as registration, payments or keys) blocking functionality of the extension.\r\n\r\n# Acceptable code practices\r\n\r\n* Extension code must be reviewable: no obfuscated code or byte code is allowed.\r\n\r\n* Extension must be self-contained and not load remote code for execution.\r\n\r\n* Extension must not send data to any remote locations without authorization from the user.\r\n\r\n* Extension should avoid including redundant code or files.\r\n\r\n* Extension must not negatively impact performance or stability of Blender.\r\n\r\n* Add-on's code must be compliant with the [guidelines](https://developer.blender.org/docs/handbook/addons/guidelines/).",
"enable_comments": false, "enable_comments": false,
"template_name": "", "template_name": "",
"registration_required": false, "registration_required": false,
@ -43,7 +28,7 @@
"fields": { "fields": {
"url": "/about/", "url": "/about/",
"title": "About", "title": "About",
"content": "# Blender Extensions Platform\n\nThe Blender Extensions platform is the online directory of free and Open Source extensions for Blender.\n\nThe goal of this platform is to make it easy for Blender users to find and share their add-ons and themes, entirely within the Free and Open Source spirit.\n\nThe platform only offers [**GNU GPL compliant software**](https://www.gnu.org/licenses/old-licenses/gpl-2.0.en.html).\n\n## How to get started\n\n 1. Download a recent daily build of [Blender 4.2 Apha](https://builder.blender.org).\n 2. Enable the [experimental feature](https://docs.blender.org/manual/en/latest/editors/preferences/experimental.html): Prototypes → Extensions.\n\nNow you can should be able to browse and install extensions in Blender:\n\n![User Preferences → Extensions](https://code.blender.org/wp-content/uploads/2024/02/about-1.jpg)\n\nYou can also explore the extensions in this web-site and install them via drag & dropping into Blender.\n\n### Follow these 5 simple steps\n\n1. Find an extension that suits you click on the **Get** button.\n\n!['Get Add-on' button](https://code.blender.org/wp-content/uploads/2024/02/about-2.jpg)\n\n2. Drag the button out of the website ...\n\n!['Drag and Drop into Blender' button](https://code.blender.org/wp-content/uploads/2024/02/about-3.jpg)\n\n3. ... into Blender.\n\n![Link dragged into Blender](https://code.blender.org/wp-content/uploads/2024/02/about-4.jpg)\n\n4. You will be prompted about installing and enabling the extension. Confirm to download it.\n\n![Install & Enable](https://code.blender.org/wp-content/uploads/2024/02/about-5.jpg)\n\n5. Enjoy your newly installed extension\n\n## How to publish an extension\n\n* Read the [Conditions of Use](/conditions-of-use/) and [Policies](/policies/).\n* Follow the [guidelines in the User Manual](https://docs.blender.org/manual/en/dev/extensions/index.html#how-to-create-extensions).\n* [Upload your Extension](/submit/).\n* [Wait for Review](/approval-queue/).\n\n## See also\n\n* [Conditions of Use](/conditions-of-use/)\n* [Extensions Policies](/policies/)\n* [Report a Bug](https://projects.blender.org/infrastructure/extensions-website/issues)", "content": "# Blender Extensions Platform\n\nThe Blender Extensions platform is the online directory of free and Open Source extensions for Blender.\n\nThe goal of this platform is to make it easy for Blender users to find and share their add-ons and themes, entirely within the Free and Open Source spirit.\n\nThe platform only offers [**GNU GPL compliant software**](https://www.gnu.org/licenses/old-licenses/gpl-2.0.en.html).\n\n## How to get started\n\n 1. Download a recent daily build of [Blender 4.2 Apha](https://builder.blender.org).\n 2. Enable the [experimental feature](https://docs.blender.org/manual/en/latest/editors/preferences/experimental.html): Prototypes → Extensions.\n\nNow you can should be able to browse and install extensions in Blender:\n\n![User Preferences → Extensions](https://code.blender.org/wp-content/uploads/2024/02/about-1.jpg)\n\nYou can also explore the extensions in this web-site and install them via drag & dropping into Blender.\n\n### Follow these 5 simple steps\n\n1. Find an extension that suits you click on the **Get** button.\n\n!['Get Add-on' button](https://code.blender.org/wp-content/uploads/2024/02/about-2.jpg)\n\n2. Drag the button out of the website ...\n\n!['Drag and Drop into Blender' button](https://code.blender.org/wp-content/uploads/2024/02/about-3.jpg)\n\n3. ... into Blender.\n\n![Link dragged into Blender](https://code.blender.org/wp-content/uploads/2024/02/about-4.jpg)\n\n4. You will be prompted about installing and enabling the extension. Confirm to download it.\n\n![Install & Enable](https://code.blender.org/wp-content/uploads/2024/02/about-5.jpg)\n\n5. Enjoy your newly installed extension\n\n## How to publish an extension\n\n* Read the [Terms of Service](/terms-of-service/).\n* Follow the [guidelines in the User Manual](https://docs.blender.org/manual/en/dev/extensions/index.html#how-to-create-extensions).\n* [Upload your Extension](/submit/).\n* [Wait for Review](/approval-queue/).\n\n## See also\n\n* [Terms of Service](/terms-of-service/)\n* [Report a Bug](https://projects.blender.org/infrastructure/extensions-website/issues)",
"enable_comments": false, "enable_comments": false,
"template_name": "", "template_name": "",
"registration_required": false, "registration_required": false,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

View File

@ -0,0 +1,54 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
version="1.1"
id="svg1"
width="1920"
height="1080"
viewBox="0 0 1920 1080"
sodipodi:docname="no-image.svg"
inkscape:export-filename="no-image_640x360.png"
inkscape:export-xdpi="32"
inkscape:export-ydpi="32"
inkscape:version="1.3.2 (091e20ef0f, 2023-11-25, custom)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs1" />
<sodipodi:namedview
id="namedview1"
pagecolor="#bbbbbb"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="false"
inkscape:deskcolor="#d1d1d1"
showguides="true"
inkscape:export-bgcolor="#3f3f3fff"
inkscape:zoom="0.28945313"
inkscape:cx="540.67475"
inkscape:cy="393.84614"
inkscape:window-width="1920"
inkscape:window-height="1056"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="g1" />
<g
inkscape:groupmode="layer"
inkscape:label="Image"
id="g1">
<path
fill="none"
stroke="#ffffff"
stroke-width="0.4"
d="m 823.5878,596.10633 q 0,-24.97037 20.96278,-36.37659 -20.96278,-10.78966 -20.96278,-35.76002 v -28.05313 q 0,-23.1207 16.64692,-39.76761 16.64691,-16.64692 39.76761,-16.64692 h 11.71449 q 2.15795,-23.73725 21.57934,-40.07589 19.42139,-16.33863 46.85797,-16.03036 27.43657,0.30827 46.54967,16.03036 19.1132,15.72208 21.5794,40.07589 h 12.0227 q 23.1207,0 39.4594,16.64692 16.3386,16.64691 16.6469,39.76761 v 28.05313 q 0,24.97036 -20.6544,36.37658 20.6544,10.78966 20.6544,35.76003 v 44.39177 q 0,23.12071 -16.6469,39.76761 -16.647,16.64691 -39.4594,16.33864 h -44.08347 q -24.97036,0 -36.0683,-20.65451 -10.78967,20.65451 -36.37658,20.65451 h -43.77522 q -23.1207,0 -39.76761,-16.33864 Q 823.5878,663.92708 823.5878,640.4981 Z m 240.7637,44.39177 v -44.39177 q 0,-7.70691 -5.549,-7.70691 -4.0076,0 -9.2483,3.69932 -5.2407,3.69932 -13.2559,4.00759 -11.4061,0 -19.7296,-10.48139 -8.3235,-10.48138 -8.3235,-25.8952 0,-15.4138 8.3235,-25.27863 8.3235,-9.86484 19.7296,-10.48139 8.6317,0 15.7222,6.16553 2.7744,1.84966 6.1654,1.84966 6.1656,0 6.1656,-8.01519 v -28.05313 q 0,-10.17311 -7.0903,-17.26346 -7.0905,-7.09035 -16.9553,-7.09035 h -44.08347 q -4.93242,0 -7.09034,-2.77448 -2.15793,-2.77449 0.92482,-9.55656 6.16552,-7.39863 6.16552,-15.72209 0,-11.71449 -10.48139,-19.72967 -10.48139,-8.01517 -25.58691,-8.01517 -15.10553,0 -25.89519,8.01517 -10.78968,8.01518 -10.48139,19.72967 0,6.4738 4.31586,13.56416 5.2407,8.01517 3.39104,11.4062 -1.84966,3.39105 -7.7069,3.08277 h -43.77522 q -10.1731,0 -17.26346,7.09035 -7.09034,7.09035 -7.09034,17.26346 v 28.05313 q 0,8.01519 6.16551,8.01519 2.77449,0 8.94002,-4.00761 5.24069,-4.00758 12.94759,-4.00758 11.71449,0 19.72968,10.48139 8.01517,10.48139 8.32344,25.27863 0.30829,14.79727 -8.32344,25.8952 -8.63173,11.09794 -19.72968,10.48139 -8.32346,0 -15.72208,-6.16552 -3.08276,-1.84966 -6.16553,-1.84966 -6.16551,0 -6.16551,8.01518 v 44.39177 q 0,9.55655 7.09034,16.64691 7.09036,7.09034 17.26346,7.09034 h 43.77522 q 5.85724,0 7.39862,-3.08276 1.54138,-3.08276 -3.08276,-11.40622 -4.31586,-6.16551 -4.31586,-13.56414 0,-11.71449 10.48139,-19.72967 10.48139,-8.01519 25.89519,-8.01519 15.41381,0 25.58691,8.01519 10.17312,8.01518 10.48139,19.72967 0,8.32346 -6.16552,15.72207 -3.08275,6.78208 -0.92482,9.55656 2.15792,2.77449 7.09034,2.77449 h 44.08347 q 9.8648,0 16.9553,-7.09034 7.0903,-7.09036 7.0903,-16.64691 z M 855.64853,547.09042 v 25.58693 q 1.84965,-0.92483 6.16551,-0.92483 9.86485,0 16.95519,6.16552 2.15793,2.46621 4.93242,2.46621 4.62414,0 8.63173,-6.16552 4.00759,-6.16553 3.69932,-14.48899 -0.30827,-8.32344 -3.69932,-14.18069 -3.39103,-5.85724 -8.63173,-5.85724 -0.61656,0 -5.24069,2.4662 -8.94002,5.85724 -16.64692,5.85724 -2.4662,0 -6.16551,-0.92483 z m 203.15397,24.97037 q 1.8497,0 5.549,0.92483 v -25.58691 q -1.8498,0.92483 -6.1656,0.92483 -9.5566,0 -16.3386,-6.16553 -2.4661,-2.4662 -5.549,-2.4662 -4.6241,0 -8.3234,5.85724 -3.6993,5.85725 -3.6993,14.18069 0,8.32346 3.6993,14.48899 3.6993,6.16552 8.3234,6.16552 1.2332,0 5.8573,-2.46621 8.94,-5.85725 16.6469,-5.85725 z m -111.90424,92.17456 h 25.89519 q -4.00759,-12.63932 4.93242,-22.81242 2.46621,-2.15795 2.46621,-5.2407 0,-4.62415 -5.85726,-8.32346 -5.85724,-3.6993 -14.18069,-3.6993 -8.32346,0 -14.48897,3.6993 -6.16553,3.69931 -5.54898,8.32346 0,1.2331 2.15793,5.85724 7.70691,11.09795 4.62415,22.19588 z"
horiz-adv-x="885"
id="path1"
style="fill:none;fill-opacity:1;stroke:#ffffff;stroke-width:7.27776;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

View File

@ -14,12 +14,13 @@
top: 50% top: 50%
&:last-child &:last-child
/* Remove bottom half of the vertical line for last item. */
.comment-card:before
height: calc(50% - var(--spacer))
.activity-status-change:before .activity-status-change:before
height: calc(50% + var(--border-width)) height: calc(50% + var(--border-width))
/* Change vertical line height for last item. */
.comment-card:before
height: var(--spacer-4)
&:last-child:has(.comment-card) &:last-child:has(.comment-card)
.activity-status-change:before .activity-status-change:before
height: 100% height: 100%
@ -53,6 +54,10 @@
width: calc(var(--spacer) * 2) width: calc(var(--spacer) * 2)
z-index: -1 z-index: -1
&.comment-card
> div
overflow-wrap: anywhere // prevent extremely long words from throwing flex layout off
.activity-icon .activity-icon
top: 1.2rem top: 1.2rem
@ -67,6 +72,10 @@
.activity-icon .activity-icon
top: 2.2rem top: 2.2rem
code,
pre
white-space: normal
.activity-status-change .activity-status-change
color: var(--color-text-tertiary) color: var(--color-text-tertiary)
@ -117,6 +126,4 @@
width: auto width: auto
textarea textarea
height: calc(var(--spacer) * 8)
max-height: 0
min-height: calc(var(--spacer) * 8) min-height: calc(var(--spacer) * 8)

View File

@ -302,7 +302,7 @@
.label .label
line-height: calc(var(--spacer) * 2) line-height: calc(var(--spacer) * 2)
/* Nabdrawer. */ /* Navdrawer. */
.nav-link .nav-link
&[class*=" i-"]::before &[class*=" i-"]::before
+margin(2, right) +margin(2, right)
@ -319,8 +319,6 @@
a a
color: var(--color-text) color: var(--color-text)
// TODO: @web-assets check arbitrary style table link display specificity
display: inline !important
+padding(1, y) +padding(1, y)
padding-inline: 0 !important padding-inline: 0 !important
@ -373,3 +371,19 @@
.dropdown-item .dropdown-item
&a &a
+padding(3, x) +padding(3, x)
.extension-icon
width: var(--fs-h1)
.icon-preview, .featured-image-preview
height: 9rem
background-size: contain
background-repeat: no-repeat
background-color: var(--color-bg)
border-radius: var(--border-radius)
.icon-preview
width: 9rem
.featured-image-preview
width: 16rem

View File

@ -1,4 +1,10 @@
table, table,
.table .table
a
text-decoration: underline
th
color: var(--color-text-secondary)
thead thead
white-space: normal white-space: normal

View File

@ -30,6 +30,7 @@ $container-width: map-get($container-max-widths, 'xl')
@import '_hero.sass' @import '_hero.sass'
@import '_list.sass' @import '_list.sass'
@import '_navigation_global.sass' @import '_navigation_global.sass'
@import '_notifications.sass'
@import '_table.sass' @import '_table.sass'
@import 'ratings/static/ratings/styles/_review.sass' @import 'ratings/static/ratings/styles/_review.sass'
@import 'ratings/static/ratings/styles/_stars.sass' @import 'ratings/static/ratings/styles/_stars.sass'

View File

@ -156,7 +156,7 @@
</a> </a>
</li> </li>
{% endif %} {% endif %}
{% if user.is_moderator %} {% if user_is_moderator %}
<li> <li>
<a href="{% url 'abuse:report-list' %}" class="dropdown-item"> <a href="{% url 'abuse:report-list' %}" class="dropdown-item">
<i class="i-shield"></i> {% trans "Abuse Reports" %} <i class="i-shield"></i> {% trans "Abuse Reports" %}

View File

@ -2,7 +2,10 @@
{% spaceless %} {% spaceless %}
{% with type=field.field.widget.input_type classes=classes|default:"" placeholder=placeholder|default:"" %} {% with type=field.field.widget.input_type classes=classes|default:"" placeholder=placeholder|default:"" %}
{% with field=field|remove_cols_rows|add_classes:classes|set_placeholder:placeholder %} {% with field=field|remove_cols_rows|add_classes:classes|set_placeholder:placeholder %}
{% autoescape off %}
{% firstof label field.label as label %} {% firstof label field.label as label %}
{% firstof help_text field.help_text as help_text %}
{% endautoescape %}
{% comment %} Checkboxes {% endcomment %} {% comment %} Checkboxes {% endcomment %}
{% if type == 'checkbox' %} {% if type == 'checkbox' %}
@ -37,8 +40,8 @@
{% endif %} {% endif %}
{% endif %} {% endif %}
{% if field.help_text %} {% if help_text and not field.is_hidden %}
<div class="form-text">{{ field.help_text|safe }}</div> <div class="form-text">{{ help_text|safe }}</div>
{% endif %} {% endif %}
{% if field.errors %} {% if field.errors %}

View File

@ -61,6 +61,8 @@ EXTENSION_SLUGS_PATH = '|'.join(EXTENSION_TYPE_SLUGS.values())
EXTENSION_SLUG_TYPES = {v: k for k, v in EXTENSION_TYPE_SLUGS_SINGULAR.items()} EXTENSION_SLUG_TYPES = {v: k for k, v in EXTENSION_TYPE_SLUGS_SINGULAR.items()}
ALLOWED_EXTENSION_MIMETYPES = ('application/zip',) ALLOWED_EXTENSION_MIMETYPES = ('application/zip',)
ALLOWED_FEATURED_IMAGE_MIMETYPES = ('image/jpg', 'image/jpeg', 'image/png', 'image/webp')
ALLOWED_ICON_MIMETYPES = ('image/png',)
# FIXME: this controls the initial widget rendered server-side, and server-side validation # FIXME: this controls the initial widget rendered server-side, and server-side validation
# but not the additional JS-appended preview file inputs. # but not the additional JS-appended preview file inputs.
# If this list changes, the "accept" attribute also has to be updated in appendImageUploadForm. # If this list changes, the "accept" attribute also has to be updated in appendImageUploadForm.

View File

@ -62,6 +62,7 @@ class ExtensionAdmin(admin.ModelAdmin):
'website', 'website',
) )
raw_id_fields = ('team',) raw_id_fields = ('team',)
autocomplete_fields = ('icon', 'featured_image')
fieldsets = ( fieldsets = (
( (
@ -79,6 +80,7 @@ class ExtensionAdmin(admin.ModelAdmin):
'name', 'name',
'slug', 'slug',
'description', 'description',
('icon', 'featured_image'),
'status', 'status',
), ),
}, },

View File

@ -2,12 +2,15 @@ import logging
from django import forms from django import forms
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
import django.core.exceptions
from files.validators import FileMIMETypeValidator from constants.base import (
from constants.base import ALLOWED_PREVIEW_MIMETYPES ALLOWED_FEATURED_IMAGE_MIMETYPES,
ALLOWED_ICON_MIMETYPES,
ALLOWED_PREVIEW_MIMETYPES,
)
import extensions.models import extensions.models
import files.forms
import files.models import files.models
import reviewers.models import reviewers.models
@ -38,61 +41,22 @@ EditPreviewFormSet = forms.inlineformset_factory(
) )
class AddPreviewFileForm(forms.ModelForm): class AddPreviewFileForm(files.forms.BaseMediaFileForm):
msg_unexpected_file_type = _('Choose a JPEG, PNG or WebP image, or an MP4 video') allowed_mimetypes = ALLOWED_PREVIEW_MIMETYPES
error_messages = {'invalid_mimetype': _('Choose a JPEG, PNG or WebP image, or an MP4 video')}
class Meta: class Meta(files.forms.BaseMediaFileForm.Meta):
model = files.models.File fields = ('caption',) + files.forms.BaseMediaFileForm.Meta.fields
fields = ('caption', 'source', 'original_hash', 'hash')
widgets = {'original_hash': forms.HiddenInput(), 'hash': forms.HiddenInput()}
source = forms.FileField(
allow_empty_file=False,
required=True,
validators=[
FileMIMETypeValidator(
allowed_mimetypes=ALLOWED_PREVIEW_MIMETYPES,
message=msg_unexpected_file_type,
),
],
widget=forms.ClearableFileInput(
attrs={'accept': ','.join(ALLOWED_PREVIEW_MIMETYPES)},
),
)
caption = forms.CharField(max_length=255, required=False) caption = forms.CharField(max_length=255, required=False)
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.request = kwargs.pop('request') self.base_fields['source'].required = True
self.extension = kwargs.pop('extension')
self.base_fields['caption'].widget.attrs.update({'placeholder': 'Describe the preview'}) self.base_fields['caption'].widget.attrs.update({'placeholder': 'Describe the preview'})
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
def clean_original_hash(self, *args, **kwargs):
"""Calculate original hash of the uploaded file."""
if 'source' not in self.cleaned_data:
return
source = self.cleaned_data['source']
return files.models.File.generate_hash(source)
def clean_hash(self, *args, **kwargs):
return self.cleaned_data['original_hash']
def add_error(self, field, error):
"""Add hidden `original_hash`/`hash` errors to the visible `source` field instead."""
if isinstance(error, django.core.exceptions.ValidationError):
if getattr(error, 'error_dict', None):
hash_error = error.error_dict.pop('hash', None)
if hash_error:
error.error_dict['source'] = hash_error
# `original_hash` is treated identically to `hash`, so its errors can be discarded
error.error_dict.pop('original_hash', None)
super().add_error(field, error)
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
"""Save Preview from the cleaned form data.""" """Save Preview from the cleaned form data."""
# Fill in missing fields from request and the source file
self.instance.user = self.request.user
instance = super().save(*args, **kwargs) instance = super().save(*args, **kwargs)
# Create extension preview and save caption to it # Create extension preview and save caption to it
@ -169,24 +133,46 @@ class ExtensionUpdateForm(forms.ModelForm):
extension=self.instance, extension=self.instance,
request=self.request, request=self.request,
) )
featured_image_form = FeaturedImageForm(
self.request.POST,
self.request.FILES,
extension=self.instance,
request=self.request,
)
icon_form = IconForm(
self.request.POST,
self.request.FILES,
extension=self.instance,
request=self.request,
)
else: else:
edit_preview_formset = EditPreviewFormSet(instance=self.instance) edit_preview_formset = EditPreviewFormSet(instance=self.instance)
add_preview_formset = AddPreviewFormSet(extension=self.instance, request=self.request) add_preview_formset = AddPreviewFormSet(extension=self.instance, request=self.request)
featured_image_form = FeaturedImageForm(extension=self.instance, request=self.request)
icon_form = IconForm(extension=self.instance, request=self.request)
self.edit_preview_formset = edit_preview_formset self.edit_preview_formset = edit_preview_formset
self.add_preview_formset = add_preview_formset self.add_preview_formset = add_preview_formset
self.featured_image_form = featured_image_form
self.icon_form = icon_form
self.add_preview_formset.error_messages['too_few_forms'] = self.msg_need_previews self.add_preview_formset.error_messages['too_few_forms'] = self.msg_need_previews
def is_valid(self, *args, **kwargs) -> bool: def is_valid(self, *args, **kwargs) -> bool:
"""Validate all nested forms and form(set)s first.""" """Validate all nested forms and form(set)s first."""
# Require at least one preview image when requesting a review
if 'submit_draft' in self.data: if 'submit_draft' in self.data:
# Require at least one preview image when requesting a review
if not self.instance.previews.exists(): if not self.instance.previews.exists():
self.add_preview_formset.min_num = 1 self.add_preview_formset.min_num = 1
self.add_preview_formset.validate_min = True self.add_preview_formset.validate_min = True
# Make feature image and icon required too
self.featured_image_form.fields['source'].required = True
self.icon_form.fields['source'].required = True
is_valid_flags = [ is_valid_flags = [
self.edit_preview_formset.is_valid(), self.edit_preview_formset.is_valid(),
self.add_preview_formset.is_valid(), self.add_preview_formset.is_valid(),
self.featured_image_form.is_valid(),
self.icon_form.is_valid(),
super().is_valid(*args, **kwargs), super().is_valid(*args, **kwargs),
] ]
return all(is_valid_flags) return all(is_valid_flags)
@ -212,6 +198,14 @@ class ExtensionUpdateForm(forms.ModelForm):
"""Save the nested form(set)s, then the main form.""" """Save the nested form(set)s, then the main form."""
self.edit_preview_formset.save() self.edit_preview_formset.save()
self.add_preview_formset.save() self.add_preview_formset.save()
# Featured image and icon are only required when ready for review,
# and can be empty or unchanged.
if self.featured_image_form.has_changed():
self.featured_image_form.save()
if self.icon_form.has_changed():
self.icon_form.save()
if getattr(self.instance, 'converted_to_draft', False): if getattr(self.instance, 'converted_to_draft', False):
reviewers.models.ApprovalActivity( reviewers.models.ApprovalActivity(
user=self.request.user, user=self.request.user,
@ -259,3 +253,17 @@ class VersionDeleteForm(forms.ModelForm):
class Meta: class Meta:
model = extensions.models.Version model = extensions.models.Version
fields = [] fields = []
class FeaturedImageForm(files.forms.BaseMediaFileForm):
prefix = 'featured-image'
to_field = 'featured_image'
allowed_mimetypes = ALLOWED_FEATURED_IMAGE_MIMETYPES
error_messages = {'invalid_mimetype': _('Choose a JPEG, PNG or WebP image')}
class IconForm(files.forms.BaseMediaFileForm):
prefix = 'icon'
to_field = 'icon'
allowed_mimetypes = ALLOWED_ICON_MIMETYPES
error_messages = {'invalid_mimetype': _('Choose a PNG image')}

View File

@ -0,0 +1,30 @@
from django.db import migrations
def rename_flatpage(apps, schema_editor):
FlatPage = apps.get_model('flatpages', 'FlatPage')
try:
# Rename 'policies' flatpage to 'terms-of-service'
policies_flatpage = FlatPage.objects.get(url='/policies/')
policies_flatpage.url = '/terms-of-service/'
policies_flatpage.title = 'Terms of Service'
policies_flatpage.save()
except FlatPage.DoesNotExist:
pass
try:
# Delete 'conditions-of-use' flatpage
conditions_flatpage = FlatPage.objects.get(url='/conditions-of-use/')
conditions_flatpage.delete()
except FlatPage.DoesNotExist:
pass
class Migration(migrations.Migration):
dependencies = [
('flatpages', '__latest__'),
('extensions', '0029_extension_featured_image_extension_icon'),
]
operations = [
migrations.RunPython(rename_flatpage),
]

View File

@ -0,0 +1,25 @@
# Generated by Django 4.2.11 on 2024-05-06 17:20
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('files', '0008_alter_file_thumbnail'),
('extensions', '0028_alter_license_slug_alter_versionpermission_slug'),
]
operations = [
migrations.AddField(
model_name='extension',
name='featured_image',
field=models.OneToOneField(help_text='Shown by social networks when this extension is shared (used as `og:image` metadata field).Should have resolution of at least 1920 x 1080 and aspect ratio of 16:9.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='featured_image_of', to='files.file'),
),
migrations.AddField(
model_name='extension',
name='icon',
field=models.OneToOneField(help_text='A 256 x 256 icon representing this extension.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='icon_of', to='files.file'),
),
]

View File

@ -144,13 +144,29 @@ class Extension(CreatedModifiedMixin, RatingMixin, TrackChangesMixin, models.Mod
help_text='Whether the extension should be listed. It is kept in sync via signals.', help_text='Whether the extension should be listed. It is kept in sync via signals.',
default=False, default=False,
) )
previews = FilterableManyToManyField(
featured_image = models.OneToOneField(
'files.File', 'files.File',
through='Preview', related_name='featured_image_of',
related_name='extensions', null=True,
# TODO: filter only images and videos. blank=False,
# q_filter=Q(type=FILE_TYPE_CHOICES.IMAGE), on_delete=models.SET_NULL,
help_text=(
"Shown by social networks when this extension is shared"
" (used as `og:image` metadata field)."
"Should have resolution of at least 1920 x 1080 and aspect ratio of 16:9."
),
) )
icon = models.OneToOneField(
'files.File',
related_name='icon_of',
null=True,
blank=False,
on_delete=models.SET_NULL,
help_text="A 256 x 256 icon representing this extension.",
)
previews = FilterableManyToManyField('files.File', through='Preview', related_name='extensions')
status = models.PositiveSmallIntegerField(choices=STATUSES, default=STATUSES.INCOMPLETE) status = models.PositiveSmallIntegerField(choices=STATUSES, default=STATUSES.INCOMPLETE)
support = models.URLField( support = models.URLField(
help_text='URL for reporting issues or contact details for support.', null=True, blank=True help_text='URL for reporting issues or contact details for support.', null=True, blank=True
@ -219,6 +235,13 @@ class Extension(CreatedModifiedMixin, RatingMixin, TrackChangesMixin, models.Mod
self.status = self.STATUSES.APPROVED self.status = self.STATUSES.APPROVED
self.save() self.save()
if self.featured_image:
self.featured_image.status = FILE_STATUS_CHOICES.APPROVED
self.save()
if self.icon:
self.icon.status = FILE_STATUS_CHOICES.APPROVED
self.icon.save()
@property @property
def cannot_be_deleted_reasons(self) -> List[str]: def cannot_be_deleted_reasons(self) -> List[str]:
"""Return a list of reasons why this extension cannot be deleted.""" """Return a list of reasons why this extension cannot be deleted."""
@ -266,12 +289,16 @@ class Extension(CreatedModifiedMixin, RatingMixin, TrackChangesMixin, models.Mod
def get_review_url(self): def get_review_url(self):
return reverse('reviewers:approval-detail', args=[self.slug]) return reverse('reviewers:approval-detail', args=[self.slug])
def get_previews(self): def get_previews(self) -> List['Preview']:
"""Get preview files, sorted by Preview.position. """Get all preview files, sorted by Preview.position.
Avoid triggering additional querysets, rely on prefetch_related in the view. Avoid triggering additional querysets, rely on prefetch_related in the view.
""" """
return [p.file for p in self.preview_set.all() if p.file.is_listed] return [p for p in self.preview_set.all()]
def get_previews_listed(self) -> List['Preview']:
"""Get publicly listed preview files, sorted by Preview.position."""
return [p for p in self.get_previews() if p.file.is_listed]
@property @property
def valid_file_statuses(self) -> List[int]: def valid_file_statuses(self) -> List[int]:

View File

@ -29,14 +29,27 @@ def _log_deletion(
instance.record_deletion() instance.record_deletion()
def _delete_file(f, sender, instance, rel):
source = f.source.name
args = {'f_id': f.pk, 'h': f.hash, 'pk': instance.pk, 'sender': sender, 's': source, 'r': rel}
logger.info('Deleting %(r)s file pk=%(f_id)s s=%(s)s hash=%(h)s of %(sender)s pk=%(pk)s', args)
f.delete()
@receiver(post_delete, sender=extensions.models.Preview) @receiver(post_delete, sender=extensions.models.Preview)
@receiver(post_delete, sender=extensions.models.Version) @receiver(post_delete, sender=extensions.models.Version)
def _delete_file(sender: object, instance: object, **kwargs: object) -> None: def _delete_preview_or_version_file(sender: object, instance: object, **kwargs: object) -> None:
f = instance.file f = instance.file
args = {'f_id': f.pk, 'h': f.hash, 'pk': instance.pk, 'sender': sender, 's': f.source.name} _delete_file(f, sender, instance, rel=sender)
logger.info('Deleting file pk=%(f_id)s s=%(s)s hash=%(h)s linked to %(sender)s pk=%(pk)s', args)
f.delete()
# TODO: this doesn't mean that the file was deleted from disk @receiver(post_delete, sender=extensions.models.Extension)
def _delete_featured_image_and_icon(sender: object, instance: object, **kwargs: object) -> None:
for rel in ('featured_image', 'icon'):
f = getattr(instance, rel)
if not f:
continue
_delete_file(f, sender, instance, rel)
@receiver(pre_save, sender=extensions.models.Extension) @receiver(pre_save, sender=extensions.models.Extension)

View File

@ -18,7 +18,7 @@
</div> </div>
{% endblock hero_breadcrumbs %} {% endblock hero_breadcrumbs %}
<h1>{{ extension.name }}</h1> <h1>{% include "extensions/components/icon.html" %} {{ extension.name }}</h1>
<div class="hero-subtitle"> <div class="hero-subtitle">
{% if latest.tagline %} {% if latest.tagline %}

View File

@ -1,2 +1,2 @@
{# Indentation looks like this so there is no gap between the author name and comma. #} {# Indentation looks like this so there is no gap between the author name and comma. #}
{% for author in extension.authors.all %}{% if forloop.counter > 1 %},&nbsp;{% endif %}<a href="{% url "extensions:by-author" user_id=author.pk %}" title="{{ author }}">{{ author }}</a>{% endfor %} {% for author in extension.authors.all %}{% if forloop.counter > 1 %},&nbsp;{% endif %}<a class="d-inline" href="{% url "extensions:by-author" user_id=author.pk %}" title="{{ author }}">{{ author }}</a>{% endfor %}

View File

@ -1,5 +1,7 @@
{% load common filters %} {% load common filters static %}
{% with latest=extension.latest_version thumbnail_360p_url=extension.get_previews.0.thumbnail_360p_url %} {% static "common/images/no-image_640x360.png" as featured_image_missing %}
{% with latest=extension.latest_version %}
{% firstof extension.featured_image.thumbnail_360p_url featured_image_missing as thumbnail_360p_url %}
<div class="cards-item"> <div class="cards-item">
<div class="cards-item-content"> <div class="cards-item-content">
<a href="{{ extension.get_absolute_url }}"> <a href="{{ extension.get_absolute_url }}">

View File

@ -1,17 +1,17 @@
{% with previews=extension.get_previews %} {% with preview_count=previews|length %}
<section class="galleria-container" id="galleria-container"> <section class="galleria-container" id="galleria-container">
{% if previews %} {% if previews %}
<div class="galleria-items{% if previews.count > 5 %} is-many{% endif %}{% if previews.count == 1 %} is-single{% endif %}" id="galleria-items"> <div class="galleria-items{% if preview_count > 5 %} is-many{% endif %}{% if preview_count == 1 %} is-single{% endif %}" id="galleria-items">
{% for preview in previews %} {% for preview in previews %}
{% with thumbnail_1080p_url=preview.thumbnail_1080p_url %} {% with thumbnail_1080p_url=preview.file.thumbnail_1080p_url file=preview.file %}
<a <a
class="galleria-item js-galleria-item-preview galleria-item-type-{{ preview.content_type|slugify|slice:5 }}{% if forloop.first %} is-active{% endif %}" class="galleria-item js-galleria-item-preview galleria-item-type-{{ file.content_type|slugify|slice:5 }}{% if forloop.first %} is-active{% endif %}"
href="{{ thumbnail_1080p_url }}" href="{{ thumbnail_1080p_url }}"
{% if 'video' in preview.content_type %}data-galleria-video-url="{{ preview.source.url }}"{% endif %} {% if 'video' in file.content_type %}data-galleria-video-url="{{ file.source.url }}"{% endif %}
data-galleria-content-type="{{ preview.content_type }}" data-galleria-content-type="{{ file.content_type }}"
data-galleria-index="{{ forloop.counter }}"> data-galleria-index="{{ forloop.counter }}">
<img src="{{ thumbnail_1080p_url }}" alt="{{ preview.preview.caption }}"> <img src="{{ thumbnail_1080p_url }}" alt="{{ preview.caption }}">
</a> </a>
{% endwith %} {% endwith %}
{% endfor %} {% endfor %}

View File

@ -0,0 +1,3 @@
{% load static %}
<img class="extension-icon mb-2 rounded" src="{% if extension.icon.source %}{{ extension.icon.source.url }}{% else %}{% static 'common/images/no-icon.png' %}{% endif %}">

View File

@ -14,7 +14,7 @@
<div class="col-md-8 pt-2"> <div class="col-md-8 pt-2">
{# Gallery #} {# Gallery #}
{% block extension_galleria %} {% block extension_galleria %}
{% include "extensions/components/galleria.html" with extension=extension %} {% include "extensions/components/galleria.html" with extension=extension previews=extension.get_previews_listed %}
{% endblock extension_galleria %} {% endblock extension_galleria %}
{# Description #} {# Description #}
@ -62,7 +62,7 @@
{# Permissions #} {# Permissions #}
{% block extension_permissions %} {% block extension_permissions %}
{% if extension.type_slug == 'add-on' %} {% if extension.type_slug == 'add-ons' %}
<hr class="my-4"> <hr class="my-4">
<section id="permissions" class="ext-detail-permissions"> <section id="permissions" class="ext-detail-permissions">
<h2 class="mb-3">{% trans "Permissions" %}</h2> <h2 class="mb-3">{% trans "Permissions" %}</h2>

View File

@ -59,6 +59,25 @@
</div> </div>
</section> </section>
<section class="mt-4">
<h2>{% trans 'Featured image and icon' %}</h2>
<div class="previews-upload">
<div class="row">
<div class="col">
{% trans "Icon" as icon_label %}
{% trans "A 256 x 256 icon representing this extension." as icon_help_text %}
{% include "extensions/manage/components/set_image.html" with image_form=icon_form label=icon_label help_text=icon_help_text %}
</div>
<div class="col">
{% trans "Featured image" as featured_image_label %}
{% trans "Should have resolution of at least 1920 x 1080 and aspect ratio of 16:9." as featured_image_help_text %}
{% include "extensions/manage/components/set_image.html" with image_form=featured_image_form label=featured_image_label help_text=featured_image_help_text %}
</div>
</div>
</div>
</section>
<section class="mt-4"> <section class="mt-4">
<h2>{% trans 'Previews' %}</h2> <h2>{% trans 'Previews' %}</h2>
<div class="previews-upload"> <div class="previews-upload">

View File

@ -0,0 +1,33 @@
{% load common %}
{# Handles displaying and editing the featured image #}
{% with inlineform=image_form|add_form_classes %}
{% with current_file=inlineform.instance.source %}
<div class="{{ image_form.prefix }}-preview"
style="background-image: url('{% if current_file %}{{ current_file.url }}{% endif %}');"
title="{{ label }} of the extension">
</div>
{% for field in inlineform %}
{% if field.name == "source" %}
<small>
{% include "common/components/field.html" with label=label help_text=help_text %}
</small>
{% else %}
{% include "common/components/field.html" %}
{% endif %}
{% endfor %}
{{ inlineform.non_form_errors }}
<script>
(function() {
const input = document.getElementById('id_{{ image_form.prefix }}-source');
const previewEl = document.getElementsByClassName('{{ image_form.prefix }}-preview')[0];
input.addEventListener('change', function() {
const curFiles = input.files;
if (curFiles.length > 0) {
const dataUrl = URL.createObjectURL(curFiles[0]);
previewEl.style['background-image'] = `url("${dataUrl}")`;
}
});
})();
</script>
{% endwith %}
{% endwith %}

View File

@ -38,6 +38,25 @@
</div> </div>
</section> </section>
<section class="mt-4">
<h2>{% trans 'Featured image and icon' %}</h2>
<div class="previews-upload">
<div class="row">
<div class="col">
{% trans "Icon" as icon_label %}
{% trans "A 256 x 256 icon representing this extension." as icon_help_text %}
{% include "extensions/manage/components/set_image.html" with image_form=icon_form label=icon_label help_text=icon_help_text %}
</div>
<div class="col">
{% trans "Featured image" as featured_image_label %}
{% trans "Should have resolution of at least 1920 x 1080 and aspect ratio of 16:9." as featured_image_help_text %}
{% include "extensions/manage/components/set_image.html" with image_form=featured_image_form label=featured_image_label help_text=featured_image_help_text %}
</div>
</div>
</div>
</section>
<section class="mt-4"> <section class="mt-4">
<h2>{% trans 'Previews' %}</h2> <h2>{% trans 'Previews' %}</h2>
<div class="previews-upload"> <div class="previews-upload">

View File

@ -52,8 +52,7 @@
</li> </li>
<li> <li>
You have read and agree with all You have read and agree with all
<a href="/conditions-of-use/" class="text-underline"><strong>conditions of use</strong></a> and <a href="/terms-of-service/" class="text-underline"><strong>terms of service</strong></a>.</li>
and <a href="/policies/" class="text-underline"><strong>policies</strong></a>.</li>
</ul> </ul>
<div class="box-outline mt-4"> <div class="box-outline mt-4">
@ -80,6 +79,27 @@
{% endif %} {% endif %}
</span> </span>
</button> </button>
<noscript>
<div>
<hr class="mt-5">
<p>
{% trans 'You see this, because of JavaScript is disabled in your browser.' %}
</p>
<button type="submit" class="btn btn-block btn-primary mb-2 px-5 py-2">
<i class="i-upload"></i>
<span>
{% if extension %}
{% trans 'Upload New Version' %}
{% else %}
{% trans 'Upload Extension' %}
{% endif %}
</span>
</button>
<p>
{% trans 'By clicking the submit button, you agree to Blender Extensions conditions of use and policies.' %}
</p>
</div>
</noscript>
</div> </div>
{% if form.non_field_errors %} {% if form.non_field_errors %}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

View File

@ -108,6 +108,8 @@ class DeleteTest(TestCase):
'description', 'description',
'download_count', 'download_count',
'extension_id', 'extension_id',
'featured_image',
'icon',
'is_listed', 'is_listed',
'name', 'name',
'pk', 'pk',

View File

@ -150,7 +150,7 @@ class SubmitFileTest(TestCase):
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response['Location'], f'/oauth/login?next={self.url}') self.assertEqual(response['Location'], f'/oauth/login?next={self.url}')
def test_validation_errors_agreed_with_policies_required(self): def test_validation_errors_agreed_with_terms_required(self):
self.assertEqual(Extension.objects.count(), 0) self.assertEqual(Extension.objects.count(), 0)
user = UserFactory() user = UserFactory()
self.client.force_login(user) self.client.force_login(user)
@ -309,6 +309,9 @@ class SubmitFinaliseTest(TestCase):
'extension_form': [{'description': ['This field is required.']}, None], 'extension_form': [{'description': ['This field is required.']}, None],
'add_preview_formset': [[], ['Please add at least one preview.']], 'add_preview_formset': [[], ['Please add at least one preview.']],
'edit_preview_formset': [[], []], 'edit_preview_formset': [[], []],
'featured_image_form': [{'source': ['This field is required.']}, None],
'icon_form': [{'source': ['This field is required.']}, None],
'image_form': [{'source': ['This field is required.']}, None],
}, },
) )
@ -349,12 +352,18 @@ class SubmitFinaliseTest(TestCase):
} }
file_name1 = 'test_preview_image_0001.png' file_name1 = 'test_preview_image_0001.png'
file_name2 = 'test_preview_image_0002.png' file_name2 = 'test_preview_image_0002.png'
file_name3 = 'test_icon_0001.png'
file_name4 = 'test_featured_image_0001.png'
with open(TEST_FILES_DIR / file_name1, 'rb') as fp1, open( with open(TEST_FILES_DIR / file_name1, 'rb') as fp1, open(
TEST_FILES_DIR / file_name2, 'rb' TEST_FILES_DIR / file_name2, 'rb'
) as fp2: ) as fp2, open(TEST_FILES_DIR / file_name3, 'rb') as fp3, open(
TEST_FILES_DIR / file_name4, 'rb'
) as fp4:
files = { files = {
'form-0-source': fp1, 'form-0-source': fp1,
'form-1-source': fp2, 'form-1-source': fp2,
'icon-source': fp3,
'featured-image-source': fp4,
} }
response = self.client.post(self.file.get_submit_url(), {**data, **files}) response = self.client.post(self.file.get_submit_url(), {**data, **files})
@ -363,7 +372,7 @@ class SubmitFinaliseTest(TestCase):
self.assertEqual(File.objects.filter(type=File.TYPES.BPY).count(), 1) self.assertEqual(File.objects.filter(type=File.TYPES.BPY).count(), 1)
self.assertEqual(Extension.objects.count(), 1) self.assertEqual(Extension.objects.count(), 1)
self.assertEqual(Version.objects.count(), 1) self.assertEqual(Version.objects.count(), 1)
self.assertEqual(File.objects.filter(type=File.TYPES.IMAGE).count(), 2) self.assertEqual(File.objects.filter(type=File.TYPES.IMAGE).count(), 4)
# Check an add-on was created with all given fields # Check an add-on was created with all given fields
extension = Extension.objects.first() extension = Extension.objects.first()
self.assertEqual(extension.get_type_display(), 'Add-on') self.assertEqual(extension.get_type_display(), 'Add-on')
@ -403,7 +412,7 @@ class NewVersionTest(TestCase):
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertTrue(response['Location'].startswith('/oauth/login')) self.assertTrue(response['Location'].startswith('/oauth/login'))
def test_validation_errors_agreed_with_policies_required(self): def test_validation_errors_agreed_with_terms_required(self):
self.client.force_login(self.version.file.user) self.client.force_login(self.version.file.user)
with open(TEST_FILES_DIR / 'amaranth-1.0.8.zip', 'rb') as fp: with open(TEST_FILES_DIR / 'amaranth-1.0.8.zip', 'rb') as fp:

View File

@ -212,10 +212,7 @@ class UpdateTest(TestCase):
[ [
{}, {},
{'__all__': ['Please correct the duplicate values below.']}, {'__all__': ['Please correct the duplicate values below.']},
[ ['Please select another file instead of the duplicate'],
'Please select another file instead of the duplicate',
'Please select another file instead of the duplicate',
],
], ],
) )
@ -243,7 +240,7 @@ class UpdateTest(TestCase):
self.maxDiff = None self.maxDiff = None
self.assertEqual( self.assertEqual(
response.context['add_preview_formset'].forms[0].errors, response.context['add_preview_formset'].forms[0].errors,
{'source': ['File with this Hash already exists.']}, {'source': ['File with this Original hash already exists.']},
) )
def test_post_upload_validation_error_unexpected_preview_format_gif(self): def test_post_upload_validation_error_unexpected_preview_format_gif(self):

View File

@ -16,8 +16,8 @@ from .mixins import (
from extensions.forms import ( from extensions.forms import (
ExtensionDeleteForm, ExtensionDeleteForm,
ExtensionUpdateForm, ExtensionUpdateForm,
VersionForm,
VersionDeleteForm, VersionDeleteForm,
VersionForm,
) )
from extensions.models import Extension, Version from extensions.models import Extension, Version
from files.forms import FileForm from files.forms import FileForm
@ -39,9 +39,12 @@ class ExtensionDetailView(ExtensionQuerysetMixin, DetailView):
""" """
return self.get_extension_queryset().prefetch_related( return self.get_extension_queryset().prefetch_related(
'authors', 'authors',
'ratings',
'ratings__user',
'versions', 'versions',
'versions__file', 'versions__file',
'versions__file__validation', 'versions__file__validation',
'versions__permissions',
) )
def get_object(self, queryset=None): def get_object(self, queryset=None):
@ -59,6 +62,10 @@ class ExtensionDetailView(ExtensionQuerysetMixin, DetailView):
context['my_rating'] = ratings.models.Rating.get_for( context['my_rating'] = ratings.models.Rating.get_for(
self.request.user.pk, self.object.pk self.request.user.pk, self.object.pk
) )
extension = context['object']
# Add the image for "og:image" meta to the context
if extension.featured_image and extension.featured_image.is_listed:
context['default_image_path'] = extension.featured_image.thumbnail_1080p_url
return context return context
@ -126,6 +133,8 @@ class UpdateExtensionView(
context = super().get_context_data(*args, **kwargs) context = super().get_context_data(*args, **kwargs)
context['edit_preview_formset'] = context['form'].edit_preview_formset context['edit_preview_formset'] = context['form'].edit_preview_formset
context['add_preview_formset'] = context['form'].add_preview_formset context['add_preview_formset'] = context['form'].add_preview_formset
context['featured_image_form'] = context['form'].featured_image_form
context['icon_form'] = context['form'].icon_form
return context return context
@transaction.atomic @transaction.atomic
@ -348,6 +357,8 @@ class DraftExtensionView(
context['extension_form'] = extension_form context['extension_form'] = extension_form
context['edit_preview_formset'] = extension_form.edit_preview_formset context['edit_preview_formset'] = extension_form.edit_preview_formset
context['add_preview_formset'] = extension_form.add_preview_formset context['add_preview_formset'] = extension_form.add_preview_formset
context['featured_image_form'] = extension_form.featured_image_form
context['icon_form'] = extension_form.icon_form
return context return context
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):

View File

@ -6,6 +6,7 @@ import tempfile
from django import forms from django import forms
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
import django.core.exceptions
from .validators import ( from .validators import (
ExtensionIDManifestValidator, ExtensionIDManifestValidator,
@ -52,9 +53,7 @@ class FileForm(forms.ModelForm):
message=error_messages['invalid_zip_archive'], message=error_messages['invalid_zip_archive'],
), ),
], ],
widget=forms.ClearableFileInput( widget=forms.ClearableFileInput(attrs={'accept': ','.join(ALLOWED_EXTENSION_MIMETYPES)}),
attrs={'accept': ','.join(ALLOWED_EXTENSION_MIMETYPES)}
),
help_text=msg_only_zip_files, help_text=msg_only_zip_files,
) )
agreed_with_terms = forms.BooleanField( agreed_with_terms = forms.BooleanField(
@ -62,8 +61,7 @@ class FileForm(forms.ModelForm):
required=True, required=True,
label=mark_safe( label=mark_safe(
'I have read and agreed with Blender Extensions' 'I have read and agreed with Blender Extensions'
' <a href="/conditions-of-use/" target="_blank">conditions of use</a>' ' and <a href="/terms-of-service/" target="_blank">terms of service</a>'
' and <a href="/policies/" target="_blank">policies</a>'
), ),
) )
@ -155,3 +153,68 @@ class FileForm(forms.ModelForm):
self.cleaned_data['type'] = EXTENSION_SLUG_TYPES[manifest['type']] self.cleaned_data['type'] = EXTENSION_SLUG_TYPES[manifest['type']]
return self.cleaned_data return self.cleaned_data
class BaseMediaFileForm(forms.ModelForm):
class Meta:
model = files.models.File
fields = ('source', 'original_hash')
widgets = {'original_hash': forms.HiddenInput()}
source = forms.ImageField(widget=forms.FileInput)
def __init__(self, *args, **kwargs):
self.request = kwargs.pop('request')
self.extension = kwargs.pop('extension')
# Set current File so that the form actually displays it:
if hasattr(self, 'to_field'):
kwargs['instance'] = getattr(self.extension, getattr(self, 'to_field'))
source_field = self.base_fields['source']
# File might not be required depending on the context (saving draft vs sending to review)
source_field.required = False
accept = ','.join(self.allowed_mimetypes)
source_field.widget.attrs.update({'accept': accept})
# Replace ImageField's file extension validator with one that also check file's content
source_field.validators = [
FileMIMETypeValidator(
allowed_mimetypes=self.allowed_mimetypes,
message=self.error_messages['invalid_mimetype'],
)
]
super().__init__(*args, **kwargs)
self.instance.user = self.request.user
def clean_original_hash(self, *args, **kwargs):
"""Calculate original hash of the uploaded file."""
source = self.cleaned_data.get('source')
if not source:
return
return files.models.File.generate_hash(source)
def add_error(self, field, error):
"""Add hidden `original_hash` errors to the visible `source` field instead."""
if isinstance(error, django.core.exceptions.ValidationError):
if getattr(error, 'error_dict', None):
hash_error = error.error_dict.pop('original_hash', None)
if hash_error:
error.error_dict['source'] = hash_error
super(forms.ModelForm, self).add_error(field, error)
def save(self, *args, **kwargs):
"""Save as `to_field` on the parent object (Extension)."""
source = self.cleaned_data['source']
self.instance.hash = self.instance.original_hash
self.instance.original_name = source.name
self.instance.size_bytes = source.size
instance = super().save(*args, **kwargs)
if hasattr(self, 'to_field'):
to_field = self.to_field
setattr(self.extension, to_field, instance)
return instance

View File

@ -144,8 +144,9 @@ class File(CreatedModifiedMixin, TrackChangesMixin, models.Model):
self.full_clean() self.full_clean()
return super().save(*args, **kwargs) return super().save(*args, **kwargs)
def is_listed(self): @property
return self.status == self.model.STATUSES.APPROVED def is_listed(self) -> bool:
return self.status == self.STATUSES.APPROVED
@property @property
def is_image(self) -> bool: def is_image(self) -> bool:

View File

@ -1,6 +1,6 @@
{% load common i18n %} {% load common i18n %}
{# FIXME: we might want to rephrase is_moderator in terms of Django's (group) permissions #} {# FIXME: we might want to rephrase is_moderator in terms of Django's (group) permissions #}
{% if perms.files.view_file or request.user.is_moderator %} {% if perms.files.view_file or user_is_moderator %}
{% if suspicious_files %} {% if suspicious_files %}
<section> <section>
<div class="card pb-3 pt-4 px-4 mb-3 ext-detail-download-danger"> <div class="card pb-3 pt-4 px-4 mb-3 ext-detail-download-danger">

View File

@ -1,6 +1,6 @@
{% load common i18n %} {% load common i18n %}
{# FIXME: we might want to rephrase is_moderator in terms of Django's (group) permissions #} {# FIXME: we might want to rephrase is_moderator in terms of Django's (group) permissions #}
{% if perms.files.view_file or request.user.is_moderator %} {% if perms.files.view_file or user_is_moderator %}
{% if suspicious_files %} {% if suspicious_files %}
{% blocktrans asvar alert_text %}Scan of the {{ suspicious_files.0 }} indicates malicious content.{% endblocktrans %} {% blocktrans asvar alert_text %}Scan of the {{ suspicious_files.0 }} indicates malicious content.{% endblocktrans %}
<b class="text-danger pt-2" title="{{ alert_text }}"></b> <b class="text-danger pt-2" title="{{ alert_text }}"></b>

View File

@ -12,7 +12,7 @@
{% if user|unread_notification_count %} {% if user|unread_notification_count %}
<form action="{% url 'notifications:notifications-mark-read-all' %}" method="post"> <form action="{% url 'notifications:notifications-mark-read-all' %}" method="post">
{% csrf_token %} {% csrf_token %}
<button class="btn mb-3" type="submit"><i class="i-eye"></i> {% trans 'Mark all as read' %}</button> <button class="btn mb-3" type="submit"><i class="i-eye"></i> {% trans 'Mark All as Read' %}</button>
</form> </form>
{% endif %} {% endif %}
<div class="box"> <div class="box">
@ -36,19 +36,19 @@
<li class="nav-item-mark-as-read"> <li class="nav-item-mark-as-read">
<form action="{% url 'notifications:notifications-mark-read' pk=notification.pk %}" method="post"> <form action="{% url 'notifications:notifications-mark-read' pk=notification.pk %}" method="post">
{% csrf_token %} {% csrf_token %}
<button class="dropdown-item" title="Mark as read" type="submit"><i class="i-eye"></i> Mark as read </button> <button class="dropdown-item" title="Mark as Read" type="submit"><i class="i-eye"></i> Mark as Read </button>
</form> </form>
</li> </li>
{# TODO: add feature 'Mark as unread' (optional) #} {# TODO: add feature 'Mark as Unread' (optional) #}
{% comment %} {% comment %}
<li class="nav-item-mark-as-unread"> <li class="nav-item-mark-as-unread">
<form> <form>
<button class="dropdown-item" title="Mark as read" type="submit"><i class="i-eye-off"></i> Mark as unread </button> <button class="dropdown-item" title="Mark as Unread" type="submit"><i class="i-eye-off"></i> Mark as Unread </button>
</form> </form>
</li> </li>
{% endcomment %} {% endcomment %}
<li> <li>
<a class="dropdown-item" href="{% url 'extensions:by-author' user_id=notification.action.actor.pk %}"><i class="i-user"></i> View user</a> <a class="dropdown-item" href="{% url 'extensions:by-author' user_id=notification.action.actor.pk %}"><i class="i-user"></i> View User</a>
</li> </li>
</ul> </ul>
</div> </div>

View File

@ -1,5 +1,5 @@
{% extends "extensions/detail.html" %} {% extends "extensions/detail.html" %}
{% load common extensions filters i18n humanize %} {% load common extensions filters i18n humanize static %}
{% block page_title %}Review: {{ extension.name }}{% endblock page_title %} {% block page_title %}Review: {{ extension.name }}{% endblock page_title %}
@ -69,7 +69,26 @@
{% block extension_galleria %} {% block extension_galleria %}
{% include "extensions/components/galleria.html" with extension=extension %}
{% include "extensions/components/galleria.html" with extension=extension previews=extension.get_previews %}
{% static "common/images/no-image_640x360.png" as featured_image_missing %}
{% trans "Featured image" as featured_image_title %}
{% with featured_image=extension.featured_image %}{% with has_featured_image=featured_image.source.name %}
<div class="card p-2 mt-2" style="width: 18rem;" title="{{ featured_image_title }}">
<a
{% if has_featured_image %}
href="{{ featured_image.source.url }}"
target="_blank"
{% endif %}
>
<img class="card-img-top rounded" src="{% if has_featured_image %}{{ featured_image.source.url }}{% else %}{{ featured_image_missing }}{% endif %}" alt="{{ featured_image_title }}">
</a>
<div class="card-body">
<small class="card-text">{{ featured_image_title}}</small>
</div>
</div>
{% endwith %}{% endwith %}
{% endblock extension_galleria %} {% endblock extension_galleria %}

View File

@ -80,7 +80,8 @@ class ExtensionsApprovalDetailView(DetailView):
def get_queryset(self): def get_queryset(self):
return self.model.objects.prefetch_related( return self.model.objects.prefetch_related(
'authors', 'authors',
'previews', 'preview_set',
'preview_set__file',
'versions', 'versions',
).all() ).all()

View File

@ -4,29 +4,34 @@
<h1 class="mb-3">Teams</h1> <h1 class="mb-3">Teams</h1>
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<div class="row border-bottom mb-2 pb-2"> <table class="table table-hover">
<div class="col">Team name</div> <thead>
<div class="col">Role</div> <tr>
<div class="col"></div> <th class="w-100">
</div> Team name
</th>
<th>
Role
</th>
</tr>
</thead>
<tbody>
{% for team_member in user.team_users.all %} {% for team_member in user.team_users.all %}
{% with team=team_member.team %} {% with team=team_member.team %}
<div class="row"> <tr>
<div class="col">{{ team.name }}</div> <td>
<div class="col">{{ team_member.get_role_display }}</div> <a class="px-0" href="{{ team.get_absolute_url }}">{{ team.name }}</a>
<div class="col"> </td>
{% comment %} <td>
{% if team_member.is_manager %} <div class="badge">
<a href="{{ team.get_manage_url }}">Manage</a>{# TODO: add team manage page #} {{ team_member.get_role_display }}
{% else %}
<a href="{{ team.get_absolute_url }}">View</a>
{% endif %}
{% endcomment %}
<a href="{{ team.get_absolute_url }}">View</a>
</div>
</div> </div>
</td>
</tr>
{% endwith %} {% endwith %}
{% endfor %} {% endfor %}
</tbody>
</table>
</div> </div>
</div> </div>
{% endblock settings %} {% endblock settings %}