Implement Web Assets' theme system and selection, and add 'light' theme #118
@ -10,9 +10,13 @@ from django.http.request import HttpRequest
|
||||
|
||||
def extra_context(request: HttpRequest) -> Dict[str, str]:
|
||||
"""Injects some configuration values into template context."""
|
||||
user_is_moderator = False
|
||||
if request.user.is_authenticated:
|
||||
user_is_moderator = request.user.is_moderator
|
||||
return {
|
||||
'BLENDER_ID': {
|
||||
'BASE_URL': settings.BLENDER_ID['BASE_URL'],
|
||||
},
|
||||
'canonical_url': request.build_absolute_uri(request.path),
|
||||
'user_is_moderator': user_is_moderator,
|
||||
}
|
||||
|
@ -7,28 +7,13 @@
|
||||
"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 Blender’s online services or products (or the servers and networks which are connected to Blender’s services),\r\n\r\n * Violate the copyright, trademark, patent, or other intellectual property rights of others,\r\n\r\n * Violate any person’s 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",
|
||||
"pk": 2,
|
||||
"fields": {
|
||||
"url": "/policies/",
|
||||
"title": "Extensions Policies",
|
||||
"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 extension’s 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/).",
|
||||
"url": "/terms-of-service/",
|
||||
"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 extension’s 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,
|
||||
"template_name": "",
|
||||
"registration_required": false,
|
||||
@ -43,7 +28,7 @@
|
||||
"fields": {
|
||||
"url": "/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,
|
||||
"template_name": "",
|
||||
"registration_required": false,
|
||||
|
BIN
common/static/common/images/no-icon.png
Normal file
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 7.2 KiB |
54
common/static/common/images/no-image.svg
Normal 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 |
BIN
common/static/common/images/no-image_640x360.png
Normal file
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 6.2 KiB |
@ -14,12 +14,13 @@
|
||||
top: 50%
|
||||
|
||||
&:last-child
|
||||
/* Remove bottom half of the vertical line for last item. */
|
||||
.comment-card:before
|
||||
height: calc(50% - var(--spacer))
|
||||
.activity-status-change:before
|
||||
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)
|
||||
.activity-status-change:before
|
||||
height: 100%
|
||||
@ -53,6 +54,10 @@
|
||||
width: calc(var(--spacer) * 2)
|
||||
z-index: -1
|
||||
|
||||
&.comment-card
|
||||
> div
|
||||
overflow-wrap: anywhere // prevent extremely long words from throwing flex layout off
|
||||
|
||||
.activity-icon
|
||||
top: 1.2rem
|
||||
|
||||
@ -67,6 +72,10 @@
|
||||
.activity-icon
|
||||
top: 2.2rem
|
||||
|
||||
code,
|
||||
pre
|
||||
white-space: normal
|
||||
|
||||
.activity-status-change
|
||||
color: var(--color-text-tertiary)
|
||||
|
||||
@ -117,6 +126,4 @@
|
||||
width: auto
|
||||
|
||||
textarea
|
||||
height: calc(var(--spacer) * 8)
|
||||
max-height: 0
|
||||
min-height: calc(var(--spacer) * 8)
|
||||
|
@ -302,7 +302,7 @@
|
||||
.label
|
||||
line-height: calc(var(--spacer) * 2)
|
||||
|
||||
/* Nabdrawer. */
|
||||
/* Navdrawer. */
|
||||
.nav-link
|
||||
&[class*=" i-"]::before
|
||||
+margin(2, right)
|
||||
@ -319,8 +319,6 @@
|
||||
|
||||
a
|
||||
color: var(--color-text)
|
||||
// TODO: @web-assets check arbitrary style table link display specificity
|
||||
display: inline !important
|
||||
+padding(1, y)
|
||||
padding-inline: 0 !important
|
||||
|
||||
@ -373,3 +371,19 @@
|
||||
.dropdown-item
|
||||
&a
|
||||
+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
|
||||
|
@ -1,4 +1,10 @@
|
||||
table,
|
||||
.table
|
||||
a
|
||||
text-decoration: underline
|
||||
|
||||
th
|
||||
color: var(--color-text-secondary)
|
||||
|
||||
thead
|
||||
white-space: normal
|
||||
|
@ -30,6 +30,7 @@ $container-width: map-get($container-max-widths, 'xl')
|
||||
@import '_hero.sass'
|
||||
@import '_list.sass'
|
||||
@import '_navigation_global.sass'
|
||||
@import '_notifications.sass'
|
||||
@import '_table.sass'
|
||||
@import 'ratings/static/ratings/styles/_review.sass'
|
||||
@import 'ratings/static/ratings/styles/_stars.sass'
|
||||
|
@ -156,7 +156,7 @@
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if user.is_moderator %}
|
||||
{% if user_is_moderator %}
|
||||
<li>
|
||||
<a href="{% url 'abuse:report-list' %}" class="dropdown-item">
|
||||
<i class="i-shield"></i> {% trans "Abuse Reports" %}
|
||||
|
@ -2,7 +2,10 @@
|
||||
{% spaceless %}
|
||||
{% 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 %}
|
||||
{% firstof label field.label as label %}
|
||||
{% autoescape off %}
|
||||
{% firstof label field.label as label %}
|
||||
{% firstof help_text field.help_text as help_text %}
|
||||
{% endautoescape %}
|
||||
|
||||
{% comment %} Checkboxes {% endcomment %}
|
||||
{% if type == 'checkbox' %}
|
||||
@ -37,8 +40,8 @@
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if field.help_text %}
|
||||
<div class="form-text">{{ field.help_text|safe }}</div>
|
||||
{% if help_text and not field.is_hidden %}
|
||||
<div class="form-text">{{ help_text|safe }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if field.errors %}
|
||||
|
@ -60,7 +60,9 @@ EXTENSION_TYPE_PLURAL = {
|
||||
EXTENSION_SLUGS_PATH = '|'.join(EXTENSION_TYPE_SLUGS.values())
|
||||
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
|
||||
# but not the additional JS-appended preview file inputs.
|
||||
# If this list changes, the "accept" attribute also has to be updated in appendImageUploadForm.
|
||||
|
@ -62,6 +62,7 @@ class ExtensionAdmin(admin.ModelAdmin):
|
||||
'website',
|
||||
)
|
||||
raw_id_fields = ('team',)
|
||||
autocomplete_fields = ('icon', 'featured_image')
|
||||
|
||||
fieldsets = (
|
||||
(
|
||||
@ -79,6 +80,7 @@ class ExtensionAdmin(admin.ModelAdmin):
|
||||
'name',
|
||||
'slug',
|
||||
'description',
|
||||
('icon', 'featured_image'),
|
||||
'status',
|
||||
),
|
||||
},
|
||||
|
@ -2,12 +2,15 @@ import logging
|
||||
|
||||
from django import forms
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
import django.core.exceptions
|
||||
|
||||
from files.validators import FileMIMETypeValidator
|
||||
from constants.base import ALLOWED_PREVIEW_MIMETYPES
|
||||
from constants.base import (
|
||||
ALLOWED_FEATURED_IMAGE_MIMETYPES,
|
||||
ALLOWED_ICON_MIMETYPES,
|
||||
ALLOWED_PREVIEW_MIMETYPES,
|
||||
)
|
||||
|
||||
import extensions.models
|
||||
import files.forms
|
||||
import files.models
|
||||
import reviewers.models
|
||||
|
||||
@ -38,61 +41,22 @@ EditPreviewFormSet = forms.inlineformset_factory(
|
||||
)
|
||||
|
||||
|
||||
class AddPreviewFileForm(forms.ModelForm):
|
||||
msg_unexpected_file_type = _('Choose a JPEG, PNG or WebP image, or an MP4 video')
|
||||
class AddPreviewFileForm(files.forms.BaseMediaFileForm):
|
||||
allowed_mimetypes = ALLOWED_PREVIEW_MIMETYPES
|
||||
error_messages = {'invalid_mimetype': _('Choose a JPEG, PNG or WebP image, or an MP4 video')}
|
||||
|
||||
class Meta:
|
||||
model = files.models.File
|
||||
fields = ('caption', 'source', 'original_hash', 'hash')
|
||||
widgets = {'original_hash': forms.HiddenInput(), 'hash': forms.HiddenInput()}
|
||||
class Meta(files.forms.BaseMediaFileForm.Meta):
|
||||
fields = ('caption',) + files.forms.BaseMediaFileForm.Meta.fields
|
||||
|
||||
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)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.request = kwargs.pop('request')
|
||||
self.extension = kwargs.pop('extension')
|
||||
self.base_fields['source'].required = True
|
||||
self.base_fields['caption'].widget.attrs.update({'placeholder': 'Describe the preview'})
|
||||
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):
|
||||
"""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)
|
||||
|
||||
# Create extension preview and save caption to it
|
||||
@ -169,24 +133,46 @@ class ExtensionUpdateForm(forms.ModelForm):
|
||||
extension=self.instance,
|
||||
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:
|
||||
edit_preview_formset = EditPreviewFormSet(instance=self.instance)
|
||||
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.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
|
||||
|
||||
def is_valid(self, *args, **kwargs) -> bool:
|
||||
"""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:
|
||||
# Require at least one preview image when requesting a review
|
||||
if not self.instance.previews.exists():
|
||||
self.add_preview_formset.min_num = 1
|
||||
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 = [
|
||||
self.edit_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),
|
||||
]
|
||||
return all(is_valid_flags)
|
||||
@ -212,6 +198,14 @@ class ExtensionUpdateForm(forms.ModelForm):
|
||||
"""Save the nested form(set)s, then the main form."""
|
||||
self.edit_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):
|
||||
reviewers.models.ApprovalActivity(
|
||||
user=self.request.user,
|
||||
@ -259,3 +253,17 @@ class VersionDeleteForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = extensions.models.Version
|
||||
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')}
|
||||
|
30
extensions/migrations/0028_terms_flatpages_rename.py
Normal 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),
|
||||
]
|
@ -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'),
|
||||
),
|
||||
]
|
@ -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.',
|
||||
default=False,
|
||||
)
|
||||
previews = FilterableManyToManyField(
|
||||
|
||||
featured_image = models.OneToOneField(
|
||||
'files.File',
|
||||
through='Preview',
|
||||
related_name='extensions',
|
||||
# TODO: filter only images and videos.
|
||||
# q_filter=Q(type=FILE_TYPE_CHOICES.IMAGE),
|
||||
related_name='featured_image_of',
|
||||
null=True,
|
||||
blank=False,
|
||||
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)
|
||||
support = models.URLField(
|
||||
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.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
|
||||
def cannot_be_deleted_reasons(self) -> List[str]:
|
||||
"""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):
|
||||
return reverse('reviewers:approval-detail', args=[self.slug])
|
||||
|
||||
def get_previews(self):
|
||||
"""Get preview files, sorted by Preview.position.
|
||||
def get_previews(self) -> List['Preview']:
|
||||
"""Get all preview files, sorted by Preview.position.
|
||||
|
||||
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
|
||||
def valid_file_statuses(self) -> List[int]:
|
||||
|
@ -29,14 +29,27 @@ def _log_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.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
|
||||
args = {'f_id': f.pk, 'h': f.hash, 'pk': instance.pk, 'sender': sender, 's': f.source.name}
|
||||
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
|
||||
_delete_file(f, sender, instance, rel=sender)
|
||||
|
||||
|
||||
@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)
|
||||
|
@ -18,7 +18,7 @@
|
||||
</div>
|
||||
{% endblock hero_breadcrumbs %}
|
||||
|
||||
<h1>{{ extension.name }}</h1>
|
||||
<h1>{% include "extensions/components/icon.html" %} {{ extension.name }}</h1>
|
||||
|
||||
<div class="hero-subtitle">
|
||||
{% if latest.tagline %}
|
||||
|
@ -1,2 +1,2 @@
|
||||
{# 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 %}, {% 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 %}, {% endif %}<a class="d-inline" href="{% url "extensions:by-author" user_id=author.pk %}" title="{{ author }}">{{ author }}</a>{% endfor %}
|
||||
|
@ -1,5 +1,7 @@
|
||||
{% load common filters %}
|
||||
{% with latest=extension.latest_version thumbnail_360p_url=extension.get_previews.0.thumbnail_360p_url %}
|
||||
{% load common filters static %}
|
||||
{% 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-content">
|
||||
<a href="{{ extension.get_absolute_url }}">
|
||||
|
@ -1,30 +1,30 @@
|
||||
{% with previews=extension.get_previews %}
|
||||
<section class="galleria-container" id="galleria-container">
|
||||
{% if previews %}
|
||||
<div class="galleria-items{% if previews.count > 5 %} is-many{% endif %}{% if previews.count == 1 %} is-single{% endif %}" id="galleria-items">
|
||||
{% for preview in previews %}
|
||||
{% with thumbnail_1080p_url=preview.thumbnail_1080p_url %}
|
||||
<a
|
||||
class="galleria-item js-galleria-item-preview galleria-item-type-{{ preview.content_type|slugify|slice:5 }}{% if forloop.first %} is-active{% endif %}"
|
||||
href="{{ thumbnail_1080p_url }}"
|
||||
{% if 'video' in preview.content_type %}data-galleria-video-url="{{ preview.source.url }}"{% endif %}
|
||||
data-galleria-content-type="{{ preview.content_type }}"
|
||||
data-galleria-index="{{ forloop.counter }}">
|
||||
{% with preview_count=previews|length %}
|
||||
<section class="galleria-container" id="galleria-container">
|
||||
{% if previews %}
|
||||
<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 %}
|
||||
{% with thumbnail_1080p_url=preview.file.thumbnail_1080p_url file=preview.file %}
|
||||
<a
|
||||
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 }}"
|
||||
{% if 'video' in file.content_type %}data-galleria-video-url="{{ file.source.url }}"{% endif %}
|
||||
data-galleria-content-type="{{ file.content_type }}"
|
||||
data-galleria-index="{{ forloop.counter }}">
|
||||
|
||||
<img src="{{ thumbnail_1080p_url }}" alt="{{ preview.preview.caption }}">
|
||||
</a>
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="galleria-item is-empty">
|
||||
<div>
|
||||
No preview yet.
|
||||
{% if is_maintainer %}
|
||||
<a class="btn btn-primary px-5" href="{{ extension.get_manage_url }}#previews">Add a Preview</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
<img src="{{ thumbnail_1080p_url }}" alt="{{ preview.caption }}">
|
||||
</a>
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="galleria-item is-empty">
|
||||
<div>
|
||||
No preview yet.
|
||||
{% if is_maintainer %}
|
||||
<a class="btn btn-primary px-5" href="{{ extension.get_manage_url }}#previews">Add a Preview</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endwith %}
|
||||
|
3
extensions/templates/extensions/components/icon.html
Normal 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 %}">
|
@ -14,7 +14,7 @@
|
||||
<div class="col-md-8 pt-2">
|
||||
{# Gallery #}
|
||||
{% 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 %}
|
||||
|
||||
{# Description #}
|
||||
@ -62,7 +62,7 @@
|
||||
|
||||
{# Permissions #}
|
||||
{% block extension_permissions %}
|
||||
{% if extension.type_slug == 'add-on' %}
|
||||
{% if extension.type_slug == 'add-ons' %}
|
||||
<hr class="my-4">
|
||||
<section id="permissions" class="ext-detail-permissions">
|
||||
<h2 class="mb-3">{% trans "Permissions" %}</h2>
|
||||
|
@ -59,6 +59,25 @@
|
||||
</div>
|
||||
</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">
|
||||
<h2>{% trans 'Previews' %}</h2>
|
||||
<div class="previews-upload">
|
||||
|
@ -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 %}
|
@ -38,6 +38,25 @@
|
||||
</div>
|
||||
</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">
|
||||
<h2>{% trans 'Previews' %}</h2>
|
||||
<div class="previews-upload">
|
||||
|
@ -52,8 +52,7 @@
|
||||
</li>
|
||||
<li>
|
||||
You have read and agree with all
|
||||
<a href="/conditions-of-use/" class="text-underline"><strong>conditions of use</strong></a>
|
||||
and <a href="/policies/" class="text-underline"><strong>policies</strong></a>.</li>
|
||||
and <a href="/terms-of-service/" class="text-underline"><strong>terms of service</strong></a>.</li>
|
||||
</ul>
|
||||
|
||||
<div class="box-outline mt-4">
|
||||
@ -80,6 +79,27 @@
|
||||
{% endif %}
|
||||
</span>
|
||||
</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>
|
||||
|
||||
{% if form.non_field_errors %}
|
||||
|
BIN
extensions/tests/files/test_featured_image_0001.png
Normal file
Before Width: | Height: | Size: 152 KiB After Width: | Height: | Size: 152 KiB |
BIN
extensions/tests/files/test_icon_0001.png
Normal file
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
@ -108,6 +108,8 @@ class DeleteTest(TestCase):
|
||||
'description',
|
||||
'download_count',
|
||||
'extension_id',
|
||||
'featured_image',
|
||||
'icon',
|
||||
'is_listed',
|
||||
'name',
|
||||
'pk',
|
||||
|
@ -150,7 +150,7 @@ class SubmitFileTest(TestCase):
|
||||
self.assertEqual(response.status_code, 302)
|
||||
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)
|
||||
user = UserFactory()
|
||||
self.client.force_login(user)
|
||||
@ -309,6 +309,9 @@ class SubmitFinaliseTest(TestCase):
|
||||
'extension_form': [{'description': ['This field is required.']}, None],
|
||||
'add_preview_formset': [[], ['Please add at least one preview.']],
|
||||
'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_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(
|
||||
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 = {
|
||||
'form-0-source': fp1,
|
||||
'form-1-source': fp2,
|
||||
'icon-source': fp3,
|
||||
'featured-image-source': fp4,
|
||||
}
|
||||
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(Extension.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
|
||||
extension = Extension.objects.first()
|
||||
self.assertEqual(extension.get_type_display(), 'Add-on')
|
||||
@ -403,7 +412,7 @@ class NewVersionTest(TestCase):
|
||||
self.assertEqual(response.status_code, 302)
|
||||
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)
|
||||
|
||||
with open(TEST_FILES_DIR / 'amaranth-1.0.8.zip', 'rb') as fp:
|
||||
|
@ -212,10 +212,7 @@ class UpdateTest(TestCase):
|
||||
[
|
||||
{},
|
||||
{'__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.assertEqual(
|
||||
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):
|
||||
|
@ -16,8 +16,8 @@ from .mixins import (
|
||||
from extensions.forms import (
|
||||
ExtensionDeleteForm,
|
||||
ExtensionUpdateForm,
|
||||
VersionForm,
|
||||
VersionDeleteForm,
|
||||
VersionForm,
|
||||
)
|
||||
from extensions.models import Extension, Version
|
||||
from files.forms import FileForm
|
||||
@ -39,9 +39,12 @@ class ExtensionDetailView(ExtensionQuerysetMixin, DetailView):
|
||||
"""
|
||||
return self.get_extension_queryset().prefetch_related(
|
||||
'authors',
|
||||
'ratings',
|
||||
'ratings__user',
|
||||
'versions',
|
||||
'versions__file',
|
||||
'versions__file__validation',
|
||||
'versions__permissions',
|
||||
)
|
||||
|
||||
def get_object(self, queryset=None):
|
||||
@ -59,6 +62,10 @@ class ExtensionDetailView(ExtensionQuerysetMixin, DetailView):
|
||||
context['my_rating'] = ratings.models.Rating.get_for(
|
||||
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
|
||||
|
||||
|
||||
@ -126,6 +133,8 @@ class UpdateExtensionView(
|
||||
context = super().get_context_data(*args, **kwargs)
|
||||
context['edit_preview_formset'] = context['form'].edit_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
|
||||
|
||||
@transaction.atomic
|
||||
@ -348,6 +357,8 @@ class DraftExtensionView(
|
||||
context['extension_form'] = extension_form
|
||||
context['edit_preview_formset'] = extension_form.edit_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
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
|
@ -6,6 +6,7 @@ import tempfile
|
||||
from django import forms
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
import django.core.exceptions
|
||||
|
||||
from .validators import (
|
||||
ExtensionIDManifestValidator,
|
||||
@ -52,9 +53,7 @@ class FileForm(forms.ModelForm):
|
||||
message=error_messages['invalid_zip_archive'],
|
||||
),
|
||||
],
|
||||
widget=forms.ClearableFileInput(
|
||||
attrs={'accept': ','.join(ALLOWED_EXTENSION_MIMETYPES)}
|
||||
),
|
||||
widget=forms.ClearableFileInput(attrs={'accept': ','.join(ALLOWED_EXTENSION_MIMETYPES)}),
|
||||
help_text=msg_only_zip_files,
|
||||
)
|
||||
agreed_with_terms = forms.BooleanField(
|
||||
@ -62,8 +61,7 @@ class FileForm(forms.ModelForm):
|
||||
required=True,
|
||||
label=mark_safe(
|
||||
'I have read and agreed with Blender Extensions'
|
||||
' <a href="/conditions-of-use/" target="_blank">conditions of use</a>'
|
||||
' and <a href="/policies/" target="_blank">policies</a>'
|
||||
' and <a href="/terms-of-service/" target="_blank">terms of service</a>'
|
||||
),
|
||||
)
|
||||
|
||||
@ -155,3 +153,68 @@ class FileForm(forms.ModelForm):
|
||||
self.cleaned_data['type'] = EXTENSION_SLUG_TYPES[manifest['type']]
|
||||
|
||||
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
|
||||
|
@ -144,8 +144,9 @@ class File(CreatedModifiedMixin, TrackChangesMixin, models.Model):
|
||||
self.full_clean()
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
def is_listed(self):
|
||||
return self.status == self.model.STATUSES.APPROVED
|
||||
@property
|
||||
def is_listed(self) -> bool:
|
||||
return self.status == self.STATUSES.APPROVED
|
||||
|
||||
@property
|
||||
def is_image(self) -> bool:
|
||||
|
@ -1,6 +1,6 @@
|
||||
{% load common i18n %}
|
||||
{# 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 %}
|
||||
<section>
|
||||
<div class="card pb-3 pt-4 px-4 mb-3 ext-detail-download-danger">
|
||||
|
@ -1,6 +1,6 @@
|
||||
{% load common i18n %}
|
||||
{# 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 %}
|
||||
{% blocktrans asvar alert_text %}Scan of the {{ suspicious_files.0 }} indicates malicious content.{% endblocktrans %}
|
||||
<b class="text-danger pt-2" title="{{ alert_text }}">⚠</b>
|
||||
|
@ -12,7 +12,7 @@
|
||||
{% if user|unread_notification_count %}
|
||||
<form action="{% url 'notifications:notifications-mark-read-all' %}" method="post">
|
||||
{% 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>
|
||||
{% endif %}
|
||||
<div class="box">
|
||||
@ -36,19 +36,19 @@
|
||||
<li class="nav-item-mark-as-read">
|
||||
<form action="{% url 'notifications:notifications-mark-read' pk=notification.pk %}" method="post">
|
||||
{% 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>
|
||||
</li>
|
||||
{# TODO: add feature 'Mark as unread' (optional) #}
|
||||
{# TODO: add feature 'Mark as Unread' (optional) #}
|
||||
{% comment %}
|
||||
<li class="nav-item-mark-as-unread">
|
||||
<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>
|
||||
</li>
|
||||
{% endcomment %}
|
||||
<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>
|
||||
</ul>
|
||||
</div>
|
||||
|
@ -1,5 +1,5 @@
|
||||
{% 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 %}
|
||||
|
||||
@ -69,7 +69,26 @@
|
||||
|
||||
|
||||
{% 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 %}
|
||||
|
||||
|
||||
|
@ -80,7 +80,8 @@ class ExtensionsApprovalDetailView(DetailView):
|
||||
def get_queryset(self):
|
||||
return self.model.objects.prefetch_related(
|
||||
'authors',
|
||||
'previews',
|
||||
'preview_set',
|
||||
'preview_set__file',
|
||||
'versions',
|
||||
).all()
|
||||
|
||||
|
@ -4,29 +4,34 @@
|
||||
<h1 class="mb-3">Teams</h1>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div class="row border-bottom mb-2 pb-2">
|
||||
<div class="col">Team name</div>
|
||||
<div class="col">Role</div>
|
||||
<div class="col"></div>
|
||||
</div>
|
||||
{% for team_member in user.team_users.all %}
|
||||
{% with team=team_member.team %}
|
||||
<div class="row">
|
||||
<div class="col">{{ team.name }}</div>
|
||||
<div class="col">{{ team_member.get_role_display }}</div>
|
||||
<div class="col">
|
||||
{% comment %}
|
||||
{% if team_member.is_manager %}
|
||||
<a href="{{ team.get_manage_url }}">Manage</a>{# TODO: add team manage page #}
|
||||
{% else %}
|
||||
<a href="{{ team.get_absolute_url }}">View</a>
|
||||
{% endif %}
|
||||
{% endcomment %}
|
||||
<a href="{{ team.get_absolute_url }}">View</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="w-100">
|
||||
Team name
|
||||
</th>
|
||||
<th>
|
||||
Role
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for team_member in user.team_users.all %}
|
||||
{% with team=team_member.team %}
|
||||
<tr>
|
||||
<td>
|
||||
<a class="px-0" href="{{ team.get_absolute_url }}">{{ team.name }}</a>
|
||||
</td>
|
||||
<td>
|
||||
<div class="badge">
|
||||
{{ team_member.get_role_display }}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock settings %}
|
||||
|