163 Commits

Author SHA1 Message Date
Oliver Falk
56cceb5724 Add django_celery_results 2025-09-18 20:23:45 +02:00
Oliver Falk
5a005d6845 Add celery 2025-09-18 11:33:28 +02:00
Oliver Falk
493f9405dd Play around with AI avatars - nothing serious yet 2025-09-17 13:55:12 +02:00
Oliver Falk
a641572e4b Adjustments for Bluesky based avatar 2025-09-16 12:49:34 +02:00
Oliver Falk
30f94610bd Improve form button layout and hero section centering
- Fix login page button spacing with proper gap between buttons
- Add responsive button group styling for all forms (login, registration, preferences, etc.)
- Implement mobile-first button layout: horizontal on desktop, vertical stack on mobile
- Center hero section buttons on front page with explicit flexbox centering
- Add theme-resistant CSS overrides to ensure consistent button appearance
- Update HTML structure to properly contain buttons within btn-group containers
- Enhance mobile UX with full-width buttons and touch-friendly spacing
2025-09-16 12:19:11 +02:00
Oliver Falk
55b7466eb5 Fix button text visibility across all themes
- Add CSS overrides with !important to ensure button text is visible
- Fixes invisible text on primary/secondary/danger buttons when custom themes are active
- Resolves issue where theme CSS files (red.css, green.css, clime.css) override text colors
- Ensures consistent button appearance regardless of selected theme
2025-09-16 11:46:53 +02:00
Oliver Falk
8a70ea1131 Improve mobile layout for photo assignment pages
- Replace float layout with responsive CSS Grid for photo selection
- Add proper spacing between image boxes on mobile devices
- Fix button overflow issues with responsive flexbox layout
- Consolidate duplicate CSS into main stylesheet
- Apply improvements to both email and OpenID assignment templates
2025-09-16 11:22:10 +02:00
Oliver Falk
b69f08694a We don't need that debug statement any more 2025-09-15 14:40:15 +02:00
Oliver Falk
ed27493abc Simple logic error and some Bluesky (still beta) fixes 2025-09-15 09:49:53 +02:00
Oliver Falk
f7d72c18fb This is creating a lot of noise and caching now anyway happens more on Apache side - use debug logging 2025-09-13 18:20:22 +02:00
Oliver Falk
7a1e38ab50 Use the hash value from the URL instead, less compute intense and more reliable 2025-09-12 11:49:34 +02:00
Oliver Falk
9d390a5b19 Add last modified and etag for better caching 2025-09-11 20:07:24 +02:00
Oliver Falk
52576bbf18 Remove the debug print 2025-09-11 20:00:47 +02:00
Oliver Falk
d720fcfa50 Rename the custom middleware to ensure it's know this is a localemiddleware. Also ensure we delete the Vary header, it could be empty - still problematic 2025-09-11 19:54:40 +02:00
Oliver Falk
c6e1583e7e Merge with master 2025-09-11 14:32:28 +02:00
Oliver Falk
5114b4d5d0 We actually need to implement this via Middleware, as the Locale Middleware comes later in the process and hinders us from removing the header. Anyway, it's cleaner, since we're not duplicating code 2025-09-11 14:22:34 +02:00
Oliver Falk
f81d6bb84c Merge branch 'devel' into 'master'
Hotfixes from devel

See merge request oliver/ivatar!248
2025-09-11 14:18:41 +02:00
Oliver Falk
16dd861953 Hotfixes from devel 2025-09-11 14:18:41 +02:00
Oliver Falk
4316c2bcc6 Merge branch 'master' into devel 2025-09-11 13:59:34 +02:00
Oliver Falk
b711594c1f We need to ensure the Vary setting isn't set of image responses 2025-09-11 13:55:08 +02:00
Oliver Falk
0d16b1f518 Remove the token auth - that was a bad idea. We may look into implementing a full oauth solution at a later point in time 2025-09-09 10:42:16 +02:00
Oliver Falk
4abbfeaa36 Handle next parameter on the token auth page to correctly redirect to the initiating page 2025-09-08 12:52:24 +02:00
Oliver Falk
13f13f7443 The login page is missing the next parameters, preventing it from working properly 2025-09-08 10:55:23 +02:00
Oliver Falk
f5c8cda222 Login page didn't respect the next parameter, bad UX. Fixed. 2025-09-08 10:37:22 +02:00
Oliver Falk
59c8db6aec Unauthenticated accesses need to be redirected for the full auth flow to work properly 2025-09-08 10:30:21 +02:00
Oliver Falk
ebfcd67512 One more fix for multiple objects 2025-09-07 17:18:18 +02:00
Oliver Falk
0df2af4f4b Unauthenticated requests leads to error - fix that case 2025-09-07 12:53:37 +02:00
Oliver Falk
797ec91320 Missing constraint 2025-09-07 11:59:48 +02:00
Oliver Falk
85c06cd42c §Fix and enhance tests, esp. remove occurances of hardcoded username/pass/email. Also treat request to admin group special and also allow superusers, which is a flag on the userobject and not a group 2025-09-06 11:13:11 +02:00
Oliver Falk
aa742ea181 Implement ExternalAuth for token based authorization 2025-09-06 10:28:50 +02:00
Oliver Falk
deeaab7e23 Identation fixes 2025-09-06 10:26:56 +02:00
Oliver Falk
0832ac9fe0 Some syntax adjustments 2025-09-06 10:26:18 +02:00
Oliver Falk
b3f580e51b Merge branch 'devel' into 'master'
Pull in fixes and updates from devel

See merge request oliver/ivatar!247
2025-08-23 16:17:58 +02:00
Oliver Falk
12bc7e5af4 Filter is a reserved word 2025-08-23 15:35:36 +02:00
Oliver Falk
e44a84e9ae Add thanks for Ezequiel 2025-08-23 15:35:36 +02:00
Oliver Falk
a1d13ba3ce MAX_ENTRIES for PyMemcacheCache doesn't work with all versions - remove it. 2025-08-13 21:40:37 +02:00
Oliver Falk
aa3e1e48dc Merge branch 'master' into devel 2025-05-24 16:31:44 +02:00
Oliver Falk
919ed4d485 We need to check if we already have an ID 2025-05-24 16:30:21 +02:00
Oliver Falk
cb7328fe23 Initial shot at invalidating the cache entry - to be confirmed to work (read: TODO) 2025-05-24 16:16:00 +02:00
Oliver Falk
1892e9585e Increase cache entries 2025-05-24 16:15:02 +02:00
Oliver Falk
7d0d2f931b Merge branch 'devel' into 'master'
Update exception handling for Bluesky to Email

See merge request oliver/ivatar!246
2025-05-07 10:28:12 +02:00
Oliver Falk
5dcd69f332 Handle exceptions during Bluesky assignment to email the same way we handle it for OpenID 2025-05-07 10:13:01 +02:00
Oliver Falk
1560a5d1de Merge branch 'devel' into 'master'
Quick fix to handle an exception in Bluesky handle assignment

See merge request oliver/ivatar!245
2025-05-07 09:06:41 +02:00
Oliver Falk
1f17526fac Move the setting of the handle up to ensure we catch the potential exception as well 2025-05-07 08:47:19 +02:00
Oliver Falk
c36d0fb808 Merge branch 'devel' into 'master'
Merge devel for updating the Bluesky test handles

See merge request oliver/ivatar!244
2025-05-06 14:25:41 +02:00
Oliver Falk
771a386bf4 Change to the libravatar handle 2025-05-06 13:17:31 +02:00
Oliver Falk
c109b70901 Merge branch 'devel' into 'master'
Bluesky integration and Fedora OIDC integration (because OpenID is going to be deprecated)

See merge request oliver/ivatar!243
2025-05-06 11:55:10 +02:00
Oliver Falk
b8996e7b00 Ensure we're sourcing the venv 2025-04-16 09:30:03 +02:00
Oliver Falk
184f3eb7f7 Use latest version from GIT, as it contains some fixes (by us) 2025-04-16 08:57:54 +02:00
Oliver Falk
27e3751776 Use newer Fedora version (investigating even newer soon) and change the registry 2025-04-16 08:57:22 +02:00
Oliver Falk
e3b0782082 Merge branch 'oidc' into 'devel'
Add support for OIDC authentication with Fedora

See merge request oliver/ivatar!242
2025-04-15 11:10:30 +00:00
Aurélien Bompard
5a0aa4838c Disable caching in the tests
Signed-off-by: Aurélien Bompard <aurelien@bompard.org>
2025-04-07 11:03:33 +02:00
Aurélien Bompard
bbacd413ca Email validation fails on some random TLDs
Signed-off-by: Aurélien Bompard <aurelien@bompard.org>
2025-04-07 11:03:33 +02:00
Aurélien Bompard
99b4fdcbcd Add support for OIDC authentication with Fedora
This adds support for authenticating with Fedora's OpenID Connect (OIDC) provider.

Existing users will be matched by email address, they should be able to use the new authentication method transparently.

This requires getting a `client_id` and a `client_secret` from Fedora Infra, see `INSTALL.md`.

Signed-off-by: Aurélien Bompard <aurelien@bompard.org>
2025-04-07 11:03:33 +02:00
Oliver Falk
c948f515e0 Remove mysqlclient - we highly recommend using PostgreSQL anyway and for dev, SQLite should be sufficient for most cases 2025-02-27 15:21:21 +01:00
Oliver Falk
8dd29e872d Removeprefix isn't available in older Python versions, replace with a while, stripping all @ and eliminate the need for this function 2025-02-26 09:25:47 +01:00
Oliver Falk
4f5f498da8 Comment out all these tests, as they all fail with the same issue 2025-02-13 15:30:34 +01:00
Oliver Falk
6c4d928a1f We still have this particular test failing under some circumstances (esp. during CI) 2025-02-13 15:09:19 +01:00
Oliver Falk
498ab7aa69 Switch return order for better readability 2025-02-13 15:07:48 +01:00
Oliver Falk
e90604a8d3 Code cleanup/refactoring 2025-02-10 16:54:28 +01:00
Oliver Falk
4a892b0c4c Lots of code cleanup, no functionality change 2025-02-10 13:27:48 +01:00
Oliver Falk
04a39f7693 Bump version - new feature deserves it 2025-02-10 10:59:24 +01:00
Oliver Falk
715725f7c9 Merge branch 'bluesky' into 'devel'
UX enhancements for Bluesky

See merge request oliver/ivatar!241
2025-02-10 09:57:55 +00:00
Oliver Falk
b86e211adc UX enhancements for Bluesky 2025-02-10 09:57:54 +00:00
Oliver Falk
6b24a7732b Merge bluesky fixes 2025-02-08 15:08:53 +01:00
Oliver Falk
488206f15b Some fixes for normalizing Bluesky handle + better error catching 2025-02-08 15:07:35 +01:00
Oliver Falk
b12b5df17a Reduce version requirement. Tested with 4.2.16 - still works fine 2025-02-07 15:44:15 +01:00
Oliver Falk
60ee28270a Merge branch 'devel' into bluesky 2025-02-07 14:51:56 +01:00
Oliver Falk
fe1113df06 Merge branch 'master' into devel 2025-02-07 14:51:34 +01:00
Oliver Falk
ad79c414da Merge branch 'bluesky' into 'devel'
Refactor: Centralize URL handling and add Bluesky integration

See merge request oliver/ivatar!240
2025-02-07 11:34:24 +00:00
Oliver Falk
3aaaac51f0 Bluesky integration
* Centralize the our urlopen for consistency.
* Fix a few tests
2025-02-07 11:34:24 +00:00
Oliver Falk
56780cba69 Merge branch 'devel' into 'master'
Move latest developments up the chain into production

See merge request oliver/ivatar!239
2025-02-07 10:43:37 +00:00
Oliver Falk
dcbd2c5df5 Patch release - no major changes
Testing fixes and stabilization
Test improvement / speed up
PostgreSQL side container for building
2025-02-07 10:43:37 +00:00
Oliver Falk
0a0a96ab6e Commit a few tests for Bluesky, now that they are in shape 2025-02-07 08:44:13 +01:00
Oliver Falk
22f9dac816 Reschuffle config slightly to ensure config_local is really processed at the end and fetch Bluesky creds from env (mainly for CI/CD purpose 2025-02-03 15:48:16 +01:00
Oliver Falk
433bf4d3e2 Put Bluesky tests into separate file, as the existing test class is already too large 2025-01-31 16:11:13 +01:00
Oliver Falk
6e0bc487cd Correct tst for redict with gravatarproxy disabled 2025-01-31 16:07:58 +01:00
Oliver Falk
6cd5b64553 Fix a few tests to work properly again 2025-01-31 14:40:01 +01:00
Oliver Falk
154e965fe3 Ensure we can add Bluesky handles and display it on the profile page 2025-01-31 12:51:51 +01:00
Oliver Falk
072783bfd5 Handle development environment differently to ensure we hit the local instance 2025-01-31 12:50:34 +01:00
Oliver Falk
259d370d9a Need to handle URL request 2025-01-31 12:49:32 +01:00
Oliver Falk
e1705cef36 Add BlueskyProxy + handle requests 2025-01-31 12:49:06 +01:00
Oliver Falk
94b0fc2068 New field bluesky_handle in both main models 2025-01-31 12:44:11 +01:00
Oliver Falk
ffab741445 Bluesky creds could be undefined, handle that exception accordingly
Signed-off-by: Oliver Falk <oliver@linux-kernel.at>
2025-01-27 08:06:12 +01:00
Oliver Falk
7559ddad6e Refactor: Centralize URL handling and add Bluesky integration
- Centralize urlopen functionality in utils.py with improved error handling
- Add configurable URL timeout across the project
- Introduce Bluesky client for fetching avatar from there (TBC)
- Support SSL verification bypass in debug mode

Signed-off-by: Oliver Falk <oliver@linux-kernel.at>
2025-01-25 13:16:04 +01:00
Oliver Falk
dc30267ff4 Don't use Argon2, as it doesn't work in old Python envs 2025-01-23 13:45:27 +01:00
Oliver Falk
3fad7497a1 Add argon2 to reqs; Fixes pipeline build as well
Signed-off-by: Oliver Falk <oliver@linux-kernel.at>
2025-01-23 13:33:49 +01:00
Oliver Falk
4a615c933b Newer Django may create this dir for djcache 2025-01-21 19:55:59 +01:00
Oliver Falk
6c25f6ea12 Pin Django to > 5.1, as older version may not work properly any more 2025-01-21 19:44:04 +01:00
Oliver Falk
cea4d5eb24 settings: Update for Django 5.1 compatibility
* Add LocaleMiddleware and i18n template context processor
* Add ATOMIC_REQUESTS for database transactions
* Adjust password validation settings:
  - Keep min length at 6 chars
* Add security settings for production environment
2025-01-21 19:42:32 +01:00
Oliver Falk
e878224ba6 Ensure we change the navbar to red-ish in case of the development instance 2025-01-08 13:52:00 +01:00
Oliver Falk
483dcf8cf7 Remove debugging statements 2024-06-25 10:36:07 +02:00
Oliver Falk
07c7f42f01 Merge branch 'master' into devel 2024-06-25 10:35:05 +02:00
Oliver Falk
b0d09e3ad4 Merge branch 'master' into devel 2024-06-25 10:34:09 +02:00
Oliver Falk
a2972ac61f Merge branch 'test-with-pgsql' into 'devel'
Use real database (side container)

See merge request oliver/ivatar!238
2024-06-25 08:32:35 +00:00
Oliver Falk
1fa5dddce5 Use real database (side container) 2024-06-25 08:32:34 +00:00
Oliver Falk
2cb868b129 Explicitly set pip cache 2024-06-24 16:50:32 +02:00
Oliver Falk
0e295401df Add pip cache 2024-06-24 16:15:00 +02:00
Oliver Falk
ecc87033cc Readd, but it's borken right now 2024-06-24 16:14:21 +02:00
Oliver Falk
2df0cdf892 Don't use CI lint args 2024-06-24 16:13:25 +02:00
Oliver Falk
cf5c058bfb Merge branch 'devel' into 'master'
Fix use of WEBP in production (file formats are all 3 letters, but WEBP is 4 letters, breaking the limited varchar(3)

Closes #97

See merge request oliver/ivatar!237
2024-05-31 15:17:45 +00:00
Oliver Falk
c2145e144f All file formats are 3 letters, but WEBP is 4, need to adjust - Closes #97 2024-05-31 17:03:00 +02:00
Oliver Falk
549289a36a Merge branch '95-logout-leading-to-http-error-405' into 'master'
Resolve "Logout leading to HTTP error 405" and add new tests to catch it next time

Closes #95

See merge request oliver/ivatar!236
2024-01-16 14:00:39 +00:00
Oliver Falk
8b0fc31f6a Resolve "Logout leading to HTTP error 405" - closing #95 2024-01-16 14:00:38 +00:00
Oliver Falk
4eedb3e628 Merge branch 'devel' into 'master'
Ensure master has the latest fixes from devel included

See merge request oliver/ivatar!235
2023-12-28 15:00:10 +00:00
Oliver Falk
049271acdd Bound/Unbound forms need deeper investigation - for the moment, disable testing several FormErrors 2023-12-28 15:42:35 +01:00
Oliver Falk
1a859af31f Use older dnspython version - something changed that is incompatible with libravatar (client) libs 2023-12-28 15:40:49 +01:00
Oliver Falk
2fe8af6fab JSONSerializer has been deprecated: https://docs.djangoproject.com/en/4.2/releases/4.1/ 2023-12-07 09:41:22 +01:00
Oliver Falk
3a61d519ba Add example issue template 2023-09-12 16:56:58 +02:00
Oliver Falk
8dff034f9e Merge branch 'devel' into 'master'
Pull in latest developments

See merge request oliver/ivatar!230
2023-09-12 14:54:33 +00:00
Oliver Falk
b58c35e98b Pillow 10.0.0 removed the ANTIALIAS alias. 2023-09-12 16:35:51 +02:00
Oliver Falk
4f239119d6 Disable image building for the moment until we figured out why it's not working 2023-09-12 16:15:16 +02:00
Oliver Falk
b7efc60cc0 Prod on f36, so move to f36 2023-06-23 10:19:44 +02:00
Oliver Falk
9faf308264 Move back to f37, we want devel to be on latest 2023-06-23 10:18:10 +02:00
Oliver Falk
b3cfccb9c0 Since prod is on 36, use 36 2023-06-23 09:37:30 +02:00
Oliver Falk
5a1dfbc459 Merge branch 'devel' into 'master'
Update CI config for sec. scanning

See merge request oliver/ivatar!229
2023-05-16 07:11:53 +00:00
Oliver Falk
df0400375d Merge branch 'set-sast-config-1' into 'devel'
Set sast config 1

See merge request oliver/ivatar!228
2023-05-15 18:58:23 +00:00
Oliver Falk
50569afc25 Set sast config 1 2023-05-15 18:58:22 +00:00
Oliver Falk
4385fcc034 Merge branch 'devel' into 'master'
Ensure working CI setup that passes the CI test

See merge request oliver/ivatar!227
2023-05-09 11:31:05 +00:00
Oliver Falk
927083eb58 Due to 'image is defined in top-level and default entry', move image into each section 2023-05-09 13:12:02 +02:00
Oliver Falk
fa4ce5e079 Merge branch 'devel' into 'master'
Reverse mr !121, since we have a b0kren ci/cd setup it seems

See merge request oliver/ivatar!225
2023-04-20 06:45:59 +00:00
Oliver Falk
a2eea54235 Reverse mr mkdir modules, since we have a b0kren ci/cd setup it seems 2023-04-19 13:27:08 +02:00
Oliver Falk
01bcc1ee11 Merge branch 'set-sast-config-1' into 'master'
Configure SAST in `.gitlab-ci.yml`, creating this file if it does not already exist

See merge request oliver/ivatar!224
2023-04-19 11:14:16 +00:00
Oliver Falk
16f809d8a6 Update .gitlab-ci.yml file 2023-04-19 10:46:31 +00:00
Oliver Falk
f01e49d495 Configure SAST in .gitlab-ci.yml, creating this file if it does not already exist 2023-04-19 10:40:05 +00:00
Oliver Falk
fd696ed74c Merge branch 'devel' into 'master'
Merge latest devel branch

See merge request oliver/ivatar!223
2023-04-17 14:17:06 +00:00
Oliver Falk
021a8de4d8 Fix typo and break up lines a bit more 2023-04-17 15:07:20 +02:00
Oliver Falk
cbdaed28da Fix docker build + update fedora base image 2023-04-17 13:44:51 +02:00
Oliver Falk
95410f6e43 Only create virtualenv on toplevel 2023-02-14 21:43:16 +01:00
Oliver Falk
2be7309625 Merge branch 'devel' into 'master'
Update produciton with latest fixes and project setup

See merge request oliver/ivatar!222
2023-02-01 16:17:37 +00:00
Oliver Falk
6deea2758f Add new dicebear endpoint (Fixes #92) 2023-02-01 16:02:10 +00:00
Oliver Falk
2bb1f5f26d Merge branch 'master' into devel 2023-01-24 21:59:46 +01:00
Oliver Falk
3878554dd9 Merge branch 'issue91' into 'master'
Closes issue #91

Closes #91

See merge request oliver/ivatar!221
2023-01-24 20:15:21 +00:00
Oliver Falk
9478177c83 Closes issue #91 2023-01-24 20:15:20 +00:00
Oliver Falk
47837f4516 Add the usual project files in order to build a tarball more easily 2023-01-03 15:37:51 +01:00
Oliver Falk
2276ea962f Merge branch 'devel' into 'master'
Update testing

See merge request oliver/ivatar!220
2023-01-03 07:41:52 +00:00
Oliver Falk
ae3c6beed4 Update testing 2023-01-03 07:41:51 +00:00
Oliver Falk
ff9af3de9b Add attic
These files are not relevant at all, but helped during research,
development, debugging. Hence, don't throw them away, but move them a
bit more out of sight.
2023-01-02 22:42:26 +01:00
Oliver Falk
7e46df0c15 Ignore local env 2023-01-02 22:40:04 +01:00
Oliver Falk
e1547d14c5 Before checking prefs, we need to login of course 2023-01-02 22:32:14 +01:00
Oliver Falk
8f5bc9653b Add __init__.py to tools/
Else the automatic test discovery ignores the directory and silently
skips the test cases.
2023-01-02 22:27:22 +01:00
Oliver Falk
5dbbff49d0 Enhance testing of checking mail + openid a bit further 2023-01-02 22:15:23 +01:00
Oliver Falk
5c8da703cb Login + really use the domain check tool 2023-01-02 21:12:30 +01:00
Oliver Falk
3aeb1ba454 Add test to delete user 2023-01-02 20:52:25 +01:00
Oliver Falk
9e189b3fd2 Update black 2022-12-29 15:15:56 +01:00
Oliver Falk
b8292b5404 Merge branch 'devel' into 'master'
Release 1.7.0

See merge request oliver/ivatar!219
2022-12-06 18:06:33 +00:00
Oliver Falk
5730c2dabf Merge branch 'webp-support' into 'devel'
Webp support

See merge request oliver/ivatar!218
2022-12-06 17:50:48 +00:00
Oliver Falk
dddd24e57f Webp support 2022-12-06 17:50:48 +00:00
Oliver Falk
a6c5899f44 Merge branch 'webp-support' into 'devel'
Webp support

See merge request oliver/ivatar!217
2022-12-05 15:56:13 +00:00
Oliver Falk
ba6f46c6eb Webp support 2022-12-05 15:56:12 +00:00
Oliver Falk
ddfc1e7824 Experimental support for Animated GIFs 2022-12-05 16:16:40 +01:00
Oliver Falk
2761e801df Add util function to resize an animated GIF 2022-12-05 16:15:30 +01:00
Oliver Falk
555a8b0523 Update pre-commit 2022-12-05 16:15:18 +01:00
Oliver Falk
6d984a486a Missing webp test file 2022-11-30 23:15:41 +01:00
Oliver Falk
9dceb7a696 Some jpgs are recognized as MPO (basically jpg with additional data 2022-11-30 11:50:29 +01:00
Oliver Falk
64575a9b99 No absolute URI required any more, actually leads to broken redir 2022-11-30 11:49:37 +01:00
Oliver Falk
a94954d58c Merge branch 'django-4.1' into 'devel'
Changes required for Django > 4

See merge request oliver/ivatar!216
2022-11-22 20:20:51 +00:00
Oliver Falk
d2e4162b6b Yes, this deserves a version increase 2022-11-22 21:03:46 +01:00
Oliver Falk
4afee63137 CACHES may not be empty 2022-11-22 20:35:13 +01:00
Oliver Falk
d486fdef2c Disable caching during tests 2022-11-22 20:26:46 +01:00
Oliver Falk
e945ae2b4d Add missing pymemcache dep and remove old one 2022-11-22 19:48:42 +01:00
Oliver Falk
9565ccc54e Changes required for Django > 4 2022-11-22 19:38:08 +01:00
Oliver Falk
e68c75d74d Update pre-commit config 2022-11-18 13:32:27 +01:00
78 changed files with 6079 additions and 1259 deletions

1
.buildpacks Normal file
View File

@@ -0,0 +1 @@
https://github.com/heroku/heroku-buildpack-python

2
.env
View File

@@ -1,8 +1,10 @@
if [ ! -d .virtualenv ]; then
if [ ! "$(which virtualenv)" == "" ]; then
if [ -f .env ]; then
virtualenv -p python3 .virtualenv
fi
fi
fi
if [ -f .virtualenv/bin/activate ]; then
source .virtualenv/bin/activate
AUTOENV_ENABLE_LEAVE=True

View File

@@ -1,5 +1,5 @@
[flake8]
ignore = E501, W503, E402, C901
ignore = E501, W503, E402, C901, E231, E702
max-line-length = 79
max-complexity = 18
select = B,C,E,F,W,T4,B9

2
.gitignore vendored
View File

@@ -20,3 +20,5 @@ falko_gravatar.jpg
*.egg-info
dump_all*.sql
dist/
.env.local
tmp/

View File

@@ -1,11 +1,32 @@
default:
image:
name: quay.io/rhn_support_ofalk/fedora35-python3
entrypoint: [ '/bin/sh', '-c' ]
name: quay.io/rhn_support_ofalk/fedora36-python3
entrypoint:
- "/bin/sh"
- "-c"
# Cache pip deps to speed up builds
cache:
paths:
- .pipcache
variables:
PIP_CACHE_DIR: .pipcache
test_and_coverage:
stage: build
coverage: "/^TOTAL.*\\s+(\\d+\\%)$/"
services:
- postgres:latest
variables:
POSTGRES_DB: django_db
POSTGRES_USER: django_user
POSTGRES_PASSWORD: django_password
POSTGRES_HOST: postgres
DATABASE_URL: "postgres://django_user:django_password@postgres/django_db"
PYTHONUNBUFFERED: 1
before_script:
- virtualenv -p python3 /tmp/.virtualenv
- source /tmp/.virtualenv/bin/activate
- pip install -U pip
- pip install Pillow
- pip install -r requirements.txt
- pip install python-coveralls
@@ -13,35 +34,43 @@ before_script:
- pip install pycco
- pip install django_coverage_plugin
test_and_coverage:
stage: test
coverage: '/^TOTAL.*\s+(\d+\%)$/'
script:
- source /tmp/.virtualenv/bin/activate
- echo 'from ivatar.settings import TEMPLATES' > config_local.py
- echo 'TEMPLATES[0]["OPTIONS"]["debug"] = True' >> config_local.py
- echo "DEBUG = True" >> config_local.py
- echo "from config import CACHES" >> config_local.py
- echo "CACHES['default'] = CACHES['filesystem']" >> config_local.py
- python manage.py sqldsn
- python manage.py collectstatic --noinput
- coverage run --source . manage.py test -v3
- coverage run --source . manage.py test -v3 --noinput
- coverage report --fail-under=70
- coverage html
artifacts:
paths:
- htmlcov/
pycco:
stage: test
before_script:
- virtualenv -p python3 /tmp/.virtualenv
- source /tmp/.virtualenv/bin/activate
- pip install -U pip
- pip install Pillow
- pip install -r requirements.txt
- pip install python-coveralls
- pip install coverage
- pip install pycco
- pip install django_coverage_plugin
script:
- /bin/true
- find ivatar/ -type f -name "*.py"|grep -v __pycache__|grep -v __init__.py|grep -v /migrations/ | xargs pycco -p -d pycco -i -s
- "/bin/true"
- find ivatar/ -type f -name "*.py"|grep -v __pycache__|grep -v __init__.py|grep
-v /migrations/ | xargs pycco -p -d pycco -i -s
artifacts:
paths:
- pycco/
expire_in: 14 days
pages:
before_script:
- /bin/true
- /bin/true
stage: deploy
dependencies:
- test_and_coverage
@@ -55,24 +84,47 @@ pages:
expire_in: 14 days
only:
- master
build-image:
image: docker
services:
- docker:dind
before_script:
- docker info
- docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" $CI_REGISTRY
#build-image:
# image: docker
# only:
# - master
# - devel
# services:
# - docker:dind
# before_script:
# - docker info
# - docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" $CI_REGISTRY
# script:
# - ls -lah
# - |
# if [[ "$CI_COMMIT_BRANCH" == "$CI_DEFAULT_BRANCH" ]]; then
# tag=""
# echo "Running on default branch '$CI_DEFAULT_BRANCH': tag = 'latest'"
# else
# tag=":$CI_COMMIT_REF_SLUG"
# echo "Running on branch '$CI_COMMIT_BRANCH': tag = $tag"
# fi
# - docker build --pull -t "$CI_REGISTRY_IMAGE${tag}" .
# - docker push "$CI_REGISTRY_IMAGE${tag}"
semgrep:
stage: test
allow_failure: true
image: registry.gitlab.com/gitlab-org/security-products/analyzers/semgrep:latest
only:
- master
- devel
variables:
CI_PROJECT_DIR: "/tmp/app"
SECURE_LOG_LEVEL: "debug"
script:
- ls -lah
- |
if [[ "$CI_COMMIT_BRANCH" == "$CI_DEFAULT_BRANCH" ]]; then
tag=""
echo "Running on default branch '$CI_DEFAULT_BRANCH': tag = 'latest'"
else
tag=":$CI_COMMIT_REF_SLUG"
echo "Running on branch '$CI_COMMIT_BRANCH': tag = $tag"
fi
- docker build --pull -t "$CI_REGISTRY_IMAGE${tag}" .
- docker push "$CI_REGISTRY_IMAGE${tag}"
- rm -rf .virtualenv
- /analyzer run
artifacts:
paths:
- gl-sast-report.json
- semgrep.sarif
include:
- template: Jobs/SAST.gitlab-ci.yml
- template: Jobs/Dependency-Scanning.gitlab-ci.yml
- template: Jobs/Secret-Detection.gitlab-ci.yml

View File

@@ -0,0 +1,5 @@
# Dscribe your issue
# What have you tried to far?
# Links / Pointer / Resources

View File

@@ -4,16 +4,16 @@ repos:
hooks:
- id: check-useless-excludes
- repo: https://github.com/pre-commit/mirrors-prettier
rev: v2.6.2
rev: v3.0.0-alpha.4
hooks:
- id: prettier
files: \.(css|js|md|markdown|json)
- repo: https://github.com/python/black
rev: 22.3.0
rev: 22.12.0
hooks:
- id: black
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.2.0
rev: v4.4.0
hooks:
- id: check-added-large-files
- id: check-ast
@@ -37,8 +37,8 @@ repos:
- id: requirements-txt-fixer
- id: sort-simple-yaml
- id: trailing-whitespace
- repo: https://gitlab.com/pycqa/flake8
rev: 3.9.2
- repo: https://github.com/PyCQA/flake8
rev: 6.0.0
hooks:
- id: flake8
- repo: local
@@ -60,13 +60,14 @@ repos:
rev: v1.12.1
hooks:
- id: blacken-docs
- repo: https://github.com/hcodes/yaspeller.git
rev: v8.0.1
hooks:
- id: yaspeller
types:
- markdown
# YASpeller does not seem to work anymore
# - repo: https://github.com/hcodes/yaspeller.git
# rev: v8.0.1
# hooks:
# - id: yaspeller
#
# types:
# - markdown
- repo: https://github.com/kadrach/pre-commit-gitlabci-lint
rev: 22d0495c9894e8b27cc37c2ed5d9a6b46385a44c
hooks:

View File

@@ -1,17 +1,22 @@
FROM quay.io/rhn_support_ofalk/fedora35-python3
FROM git.linux-kernel.at:5050/oliver/fedora40-python3:latest
LABEL maintainer Oliver Falk <oliver@linux-kernel.at>
EXPOSE 8081
RUN pip3 install pip --upgrade
ADD . /opt/ivatar-devel
WORKDIR /opt/ivatar-devel
RUN pip3 install Pillow && pip3 install -r requirements.txt && pip3 install python-coveralls coverage pycco django_coverage_plugin
RUN pip3 install pip --upgrade \
&& virtualenv .virtualenv \
&& source .virtualenv/bin/activate \
&& pip3 install Pillow \
&& pip3 install -r requirements.txt \
&& pip3 install python-coveralls coverage pycco django_coverage_plugin
RUN echo "DEBUG = True" >> /opt/ivatar-devel/config_local.py
RUN echo "EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'" >> /opt/ivatar-devel/config_local.py
RUN python3 manage.py migrate && python3 manage.py collectstatic --noinput
RUN echo "from django.contrib.auth import get_user_model; User = get_user_model(); User.objects.create_superuser('admin', 'admin@local.tld', 'admin')" | python manage.py shell
ENTRYPOINT python3 ./manage.py runserver 0:8081
RUN source .virtualenv/bin/activate \
&& python3 manage.py migrate \
&& python3 manage.py collectstatic --noinput \
&& echo "from django.contrib.auth import get_user_model; User = get_user_model(); User.objects.create_superuser('admin', 'admin@local.tld', 'admin')" | python manage.py shell
ENTRYPOINT source .virtualenv/bin/activate && python3 ./manage.py runserver 0:8081

View File

@@ -19,19 +19,19 @@ sudo apt-get install git python3-virtualenv libmariadb-dev libldap2-dev libsasl2
## Checkout
~~~~bash
```bash
git clone https://git.linux-kernel.at/oliver/ivatar.git
cd ivatar
~~~~
```
## Virtual environment
~~~~bash
```bash
virtualenv -p python3 .virtualenv
source .virtualenv/bin/activate
pip install pillow
pip install -r requirements.txt
~~~~
```
## (SQL) Migrations
@@ -58,10 +58,27 @@ pip install -r requirements.txt
```
## Running the testsuite
```
./manage.py test -v3 # Or any other verbosity level you like
```
## OpenID Connect authentication with Fedora
To enable OpenID Connect (OIDC) authentication with Fedora, you must have obtained a `client_id` and `client_secret` pair from the Fedora Infrastructure.
Then you must set these values in `config_local.py`:
```
SOCIAL_AUTH_FEDORA_KEY = "the-client-id"
SOCIAL_AUTH_FEDORA_SECRET = "the-client-secret"
```
You can override the location of the OIDC provider with the `SOCIAL_AUTH_FEDORA_OIDC_ENDPOINT` setting. For example, to authenticate with Fedora's staging environment, set this in `config_local.py`:
```
SOCIAL_AUTH_FEDORA_OIDC_ENDPOINT = "https://id.stg.fedoraproject.org"
```
# Production deployment Webserver (non-cloudy)
To deploy this Django application with WSGI on Apache, NGINX or any other web server, please refer to the the webserver documentation; There are also plenty of howtos on the net (I'll not LMGTFY...)
@@ -82,4 +99,4 @@ There is a file called ebcreate.txt as well as a directory called .ebextensions,
## Database
It should work with SQLite (do *not* use in production!), MySQL/MariaDB, as well as PostgreSQL.
It should work with SQLite (do _not_ use in production!), MySQL/MariaDB, as well as PostgreSQL.

10
MANIFEST.in Normal file
View File

@@ -0,0 +1,10 @@
include *.py
include *.md
include COPYING
include LICENSE
recursive-include templates *
recursive-include ivatar *
exclude .virtualenv
exclude libravatar.egg-info
global-exclude *.py[co]
global-exclude __pycache__

View File

@@ -1,20 +1,16 @@
ivatar / libravatar
===================
# ivatar / libravatar
Pipeline and coverage status
============================
# Pipeline and coverage status
[![pipeline status](https://git.linux-kernel.at/oliver/ivatar/badges/master/pipeline.svg)](https://git.linux-kernel.at/oliver/ivatar/commits/master)
[![coverage report](https://git.linux-kernel.at/oliver/ivatar/badges/master/coverage.svg)](http://git.linux-kernel.at/oliver/ivatar/commits/master)
Reports / code documentation
============================
# Reports / code documentation
- [Coverage HTML report](http://oliver.git.linux-kernel.at/ivatar)
- [Code documentation (autogenerated, pycco)](http://oliver.git.linux-kernel.at/ivatar/pycco/)
Authors and contributors
========================
# Authors and contributors
Lead developer/Owner: Oliver Falk (aka ofalk or falko) - https://git.linux-kernel.at/oliver

View File

@@ -0,0 +1,2 @@
https://django-debug-toolbar.readthedocs.io/en/latest/installation.html
https://stackoverflow.com/questions/6548947/how-can-django-debug-toolbar-be-set-to-work-for-just-some-users/6549317#6549317

49
attic/encryption_test.py Executable file
View File

@@ -0,0 +1,49 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
import os
import django
import timeit
os.environ.setdefault(
"DJANGO_SETTINGS_MODULE", "ivatar.settings"
) # pylint: disable=wrong-import-position
django.setup() # pylint: disable=wrong-import-position
from ivatar.ivataraccount.models import ConfirmedEmail, APIKey
from simplecrypt import decrypt
from binascii import unhexlify
digest = None
digest_sha256 = None
def get_digest_sha256():
digest_sha256 = ConfirmedEmail.objects.first().encrypted_digest_sha256(
secret_key=APIKey.objects.first()
)
return digest_sha256
def get_digest():
digest = ConfirmedEmail.objects.first().encrypted_digest(
secret_key=APIKey.objects.first()
)
return digest
def decrypt_digest():
return decrypt(APIKey.objects.first().secret_key, unhexlify(digest))
def decrypt_digest_256():
return decrypt(APIKey.objects.first().secret_key, unhexlify(digest_sha256))
digest = get_digest()
digest_sha256 = get_digest_sha256()
print("Encrypt digest: %s" % timeit.timeit(get_digest, number=1))
print("Encrypt digest_sha256: %s" % timeit.timeit(get_digest_sha256, number=1))
print("Decrypt digest: %s" % timeit.timeit(decrypt_digest, number=1))
print("Decrypt digest_sha256: %s" % timeit.timeit(decrypt_digest_256, number=1))

View File

@@ -0,0 +1,7 @@
DATABASES['default'] = {
'ENGINE': 'django.db.backends.mysql',
'NAME': 'libravatar',
'USER': 'libravatar',
'PASSWORD': 'libravatar',
'HOST': 'localhost',
}

View File

@@ -31,7 +31,7 @@ INSTALLED_APPS.extend(
MIDDLEWARE.extend(
[
"django.middleware.locale.LocaleMiddleware",
"ivatar.middleware.CustomLocaleMiddleware",
]
)
MIDDLEWARE.insert(
@@ -44,6 +44,7 @@ AUTHENTICATION_BACKENDS = (
# See INSTALL for more information.
# 'django_auth_ldap.backend.LDAPBackend',
"django_openid_auth.auth.OpenIDBackend",
"ivatar.ivataraccount.auth.FedoraOpenIdConnect",
"django.contrib.auth.backends.ModelBackend",
)
@@ -58,9 +59,13 @@ TEMPLATES[0]["OPTIONS"]["context_processors"].append(
OPENID_CREATE_USERS = True
OPENID_UPDATE_DETAILS_FROM_SREG = True
SOCIAL_AUTH_JSONFIELD_ENABLED = True
# Fedora authentication (OIDC). You need to set these two values to use it.
SOCIAL_AUTH_FEDORA_KEY = None # Also known as client_id
SOCIAL_AUTH_FEDORA_SECRET = None # Also known as client_secret
SITE_NAME = os.environ.get("SITE_NAME", "libravatar")
IVATAR_VERSION = "1.6.2"
IVATAR_VERSION = "1.8.0"
SCHEMAROOT = "https://www.libravatar.org/schemas/export/0.2"
@@ -153,7 +158,20 @@ if "POSTGRESQL_DATABASE" in os.environ:
"HOST": "postgresql",
}
SESSION_SERIALIZER = "django.contrib.sessions.serializers.PickleSerializer"
# CI/CD config has different naming
if "POSTGRES_DB" in os.environ:
DATABASES["default"] = { # pragma: no cover
"ENGINE": "django.db.backends.postgresql",
"NAME": os.environ["POSTGRES_DB"],
"USER": os.environ["POSTGRES_USER"],
"PASSWORD": os.environ["POSTGRES_PASSWORD"],
"HOST": os.environ["POSTGRES_HOST"],
"TEST": {
"NAME": os.environ["POSTGRES_DB"],
},
}
SESSION_SERIALIZER = "django.contrib.sessions.serializers.JSONSerializer"
USE_X_FORWARDED_HOST = True
ALLOWED_EXTERNAL_OPENID_REDIRECT_DOMAINS = [
@@ -191,15 +209,17 @@ MESSAGE_TAGS = {
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.memcached.MemcachedCache",
"BACKEND": "django.core.cache.backends.memcached.PyMemcacheCache",
"LOCATION": [
"127.0.0.1:11211",
],
# "OPTIONS": {"MAX_ENTRIES": 1000000},
},
"filesystem": {
"BACKEND": "django.core.cache.backends.filebased.FileBasedCache",
"LOCATION": "/var/tmp/ivatar_cache",
"TIMEOUT": 900, # 15 minutes
"OPTIONS": {"MAX_ENTRIES": 1000000},
},
}
@@ -232,15 +252,18 @@ TRUSTED_DEFAULT_URLS = [
"host_equals": "avatars.dicebear.com",
"path_prefix": "/api/",
},
{
"schemes": ["https"],
"host_equals": "api.dicebear.com",
"path_prefix": "/",
},
{
"schemes": ["https"],
"host_equals": "badges.fedoraproject.org",
"path_prefix": "/static/img/",
},
{
"schemes": [
"http",
],
"schemes": ["http"],
"host_equals": "www.planet-libre.org",
"path_prefix": "/themes/planetlibre/images/",
},
@@ -252,9 +275,7 @@ TRUSTED_DEFAULT_URLS = [
},
]
# This MUST BE THE LAST!
if os.path.isfile(os.path.join(BASE_DIR, "config_local.py")):
from config_local import * # noqa # flake8: noqa # NOQA # pragma: no cover
URL_TIMEOUT = 10
def map_legacy_config(trusted_url):
@@ -270,3 +291,38 @@ def map_legacy_config(trusted_url):
# Backward compability for legacy behavior
TRUSTED_DEFAULT_URLS = list(map(map_legacy_config, TRUSTED_DEFAULT_URLS))
# Bluesky settings
BLUESKY_IDENTIFIER = os.environ.get("BLUESKY_IDENTIFIER", None)
BLUESKY_APP_PASSWORD = os.environ.get("BLUESKY_APP_PASSWORD", None)
# Celery Configuration
# Try Redis first, fallback to memory broker for development
try:
import redis
redis.Redis(host="localhost", port=6379, db=0).ping()
CELERY_BROKER_URL = "redis://localhost:6379/0"
except Exception: # pylint: disable=broad-except
# Fallback to memory broker for development
CELERY_BROKER_URL = "memory://"
print("Warning: Redis not available, using memory broker for development")
CELERY_RESULT_BACKEND = "django-db"
CELERY_ACCEPT_CONTENT = ["json"]
CELERY_TASK_SERIALIZER = "json"
CELERY_RESULT_SERIALIZER = "json"
CELERY_TIMEZONE = "UTC"
CELERY_TASK_TRACK_STARTED = True
CELERY_TASK_TIME_LIMIT = 300 # 5 minutes
CELERY_TASK_SOFT_TIME_LIMIT = 240 # 4 minutes
CELERY_WORKER_PREFETCH_MULTIPLIER = 1
CELERY_TASK_ACKS_LATE = True
CELERY_RESULT_EXPIRES = 3600 # 1 hour
CELERY_WORKER_CONCURRENCY = (
1 # Max 1 parallel avatar generation task for local development
)
# This MUST BE THE LAST!
if os.path.isfile(os.path.join(BASE_DIR, "config_local.py")):
from config_local import * # noqa # flake8: noqa # NOQA # pragma: no cover

View File

@@ -3,4 +3,10 @@
Module init
"""
# This will make sure the app is always imported when
# Django starts so that shared_task will use this app.
from .celery import app as celery_app
__all__ = ("celery_app",)
app_label = __name__ # pylint: disable=invalid-name

461
ivatar/ai_service.py Normal file
View File

@@ -0,0 +1,461 @@
# -*- coding: utf-8 -*-
"""
AI service module for text-to-image avatar generation
Supports Stable Diffusion (local and API) for professional avatar generation
"""
import logging
import requests
import base64
from io import BytesIO
from PIL import Image
from django.conf import settings
logger = logging.getLogger(__name__)
class AIServiceError(Exception):
"""Custom exception for AI service errors"""
pass
class StableDiffusionService:
"""
Service for generating images using Stable Diffusion (local or API)
"""
# Model-specific token limits
TOKEN_LIMITS = {
"stable_diffusion": 77, # CLIP tokenizer limit
"stable_diffusion_v2": 77,
"stable_diffusion_xl": 77,
}
def __init__(self):
self.api_url = getattr(settings, "STABLE_DIFFUSION_API_URL", None)
self.api_key = getattr(settings, "STABLE_DIFFUSION_API_KEY", None)
self.timeout = getattr(settings, "STABLE_DIFFUSION_TIMEOUT", 60)
self._pipe = None # Cache for local model
self._tokenizer = None # Cache for tokenizer
def generate_image(
self, prompt, size=(512, 512), quality="medium", allow_nsfw=False
):
"""
Generate an image from text prompt using Stable Diffusion
Args:
prompt (str): Text description of the desired image
size (tuple): Image dimensions (width, height)
quality (str): Generation quality ('low', 'medium', 'high')
allow_nsfw (bool): Whether to allow potentially NSFW content
Returns:
PIL.Image: Generated image
Raises:
AIServiceError: If generation fails or prompt is too long
"""
# Validate prompt length first
validation = self.validate_prompt(prompt)
if not validation["valid"]:
raise AIServiceError(validation["warning"])
try:
if self.api_url and self.api_key:
return self._generate_via_api(prompt, size, quality, allow_nsfw)
else:
return self._generate_locally(prompt, size, quality, allow_nsfw)
except Exception as e:
logger.error(f"Failed to generate image: {e}")
raise AIServiceError(f"Image generation failed: {str(e)}")
def validate_prompt(self, prompt, model="stable_diffusion"):
"""
Validate prompt length against model token limits
Args:
prompt (str): Text prompt to validate
model (str): Model name to check limits for
Returns:
dict: Validation result with 'valid', 'token_count', 'limit', 'warning'
"""
try:
token_count = self._count_tokens(prompt)
limit = self.TOKEN_LIMITS.get(model, 77)
is_valid = token_count <= limit
warning = None
if not is_valid:
warning = f"Prompt too long: {token_count} tokens (limit: {limit}). Please shorten your prompt."
return {
"valid": is_valid,
"token_count": token_count,
"limit": limit,
"warning": warning,
}
except Exception as e:
logger.warning(f"Token counting failed: {e}")
return {
"valid": True, # Allow generation if counting fails
"token_count": 0,
"limit": 77,
"warning": None,
}
def _count_tokens(self, prompt):
"""
Count tokens in a prompt using CLIP tokenizer
"""
try:
if self._tokenizer is None:
from transformers import CLIPTokenizer
self._tokenizer = CLIPTokenizer.from_pretrained(
"openai/clip-vit-base-patch32"
)
tokens = self._tokenizer(prompt, return_tensors="pt", truncation=False)[
"input_ids"
]
return tokens.shape[1]
except ImportError:
# Fallback: more accurate estimation
# CLIP tokenizer typically produces ~1.3 tokens per word for English
words = len(prompt.split())
return int(words * 1.3)
except Exception as e:
logger.warning(f"Token counting error: {e}")
# Fallback: more accurate estimation
words = len(prompt.split())
return int(words * 1.3)
def _is_black_image(self, image):
"""
Check if an image is completely black (common NSFW response from APIs)
Args:
image (PIL.Image): Image to check
Returns:
bool: True if image is completely black
"""
# Convert to RGB if necessary
if image.mode != "RGB":
image = image.convert("RGB")
# Get image data
pixels = list(image.getdata())
# Check if all pixels are black (0, 0, 0)
black_pixels = sum(1 for r, g, b in pixels if r == 0 and g == 0 and b == 0)
total_pixels = len(pixels)
# Consider it a black image if more than 95% of pixels are black
return (black_pixels / total_pixels) > 0.95
def _generate_via_api(self, prompt, size, quality, allow_nsfw=False):
"""
Generate image via Stable Diffusion API (Replicate, Hugging Face, etc.)
"""
# Enhanced prompt for avatar generation
enhanced_prompt = f"""professional avatar portrait, {prompt}, high quality, detailed, clean background, centered composition, profile picture style, photorealistic"""
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
payload = {
"prompt": enhanced_prompt,
"width": size[0],
"height": size[1],
"num_inference_steps": 25
if quality == "high"
else (20 if quality == "medium" else 15),
"guidance_scale": 7.5, # Balanced for quality and speed
"negative_prompt": "blurry, low quality, distorted, ugly, deformed, bad anatomy",
}
# Add NSFW safety setting if supported by the API
if allow_nsfw:
payload["safety_tolerance"] = 2 # Some APIs support this
payload["nsfw"] = True # Some APIs support this
response = requests.post(
self.api_url, json=payload, headers=headers, timeout=self.timeout
)
if response.status_code != 200:
error_msg = f"Stable Diffusion API request failed: {response.status_code}"
try:
error_detail = response.json()
error_msg += f" - {error_detail}"
# Check for NSFW content detection
if isinstance(error_detail, dict):
error_text = str(error_detail).lower()
if (
"nsfw" in error_text
or "inappropriate" in error_text
or "black image" in error_text
):
if allow_nsfw:
# If user allowed NSFW but still got blocked, provide a different message
raise AIServiceError(
"Content warning: The AI service still detected inappropriate content even with relaxed settings. Please try a different prompt or contact support if you believe this is an error."
)
else:
raise AIServiceError(
"Content warning: The AI service detected potentially inappropriate content in your prompt. Please modify your description to be more appropriate for all ages and try again."
)
elif isinstance(error_detail, str):
if (
"nsfw" in error_detail.lower()
or "inappropriate" in error_detail.lower()
or "black image" in error_detail.lower()
):
if allow_nsfw:
raise AIServiceError(
"Content warning: The AI service still detected inappropriate content even with relaxed settings. Please try a different prompt or contact support if you believe this is an error."
)
else:
raise AIServiceError(
"Content warning: The AI service detected potentially inappropriate content in your prompt. Please modify your description to be more appropriate for all ages and try again."
)
except AIServiceError:
# Re-raise our custom NSFW error
raise
except Exception: # pylint: disable=broad-except
error_msg += f" - {response.text}"
# Also check response text for NSFW warnings
if (
"nsfw" in response.text.lower()
or "inappropriate" in response.text.lower()
or "black image" in response.text.lower()
):
if allow_nsfw:
raise AIServiceError(
"Content warning: The AI service still detected inappropriate content even with relaxed settings. Please try a different prompt or contact support if you believe this is an error."
)
else:
raise AIServiceError(
"Content warning: The AI service detected potentially inappropriate content in your prompt. Please modify your description to be more appropriate for all ages and try again."
)
raise AIServiceError(error_msg)
result = response.json()
if "image" in result:
# Decode base64 image
image_data = base64.b64decode(result["image"])
image = Image.open(BytesIO(image_data))
# Check if the image is completely black (common NSFW response)
if not allow_nsfw and self._is_black_image(image):
raise AIServiceError(
"Content warning: The AI service detected potentially inappropriate content in your prompt and returned a black image. Please modify your description to be more appropriate for all ages and try again."
)
return image
else:
raise AIServiceError("No image data in API response")
def _generate_locally(self, prompt, size, quality, allow_nsfw=False):
"""
Generate image using local Stable Diffusion installation
This requires diffusers library and a local model
"""
try:
from diffusers import StableDiffusionPipeline
import torch
# Enhanced prompt for avatar generation
enhanced_prompt = f"""professional avatar portrait, {prompt}, high quality, detailed, clean background, centered composition, profile picture style, photorealistic"""
# Use cached model if available, otherwise load it
if self._pipe is None:
logger.info("Loading Stable Diffusion model (first time or cache miss)")
self._pipe = StableDiffusionPipeline.from_pretrained(
"runwayml/stable-diffusion-v1-5",
torch_dtype=torch.float16
if torch.cuda.is_available()
else torch.float32,
)
if torch.cuda.is_available():
self._pipe = self._pipe.to("cuda")
else:
logger.info("Using cached Stable Diffusion model")
pipe = self._pipe
# Disable safety checker if NSFW override is enabled
if allow_nsfw:
pipe.safety_checker = None
pipe.requires_safety_checker = False
# Generate image with optimized settings for speed
image = pipe(
enhanced_prompt,
height=size[1],
width=size[0],
num_inference_steps=25
if quality == "high"
else (20 if quality == "medium" else 15),
guidance_scale=7.5, # Balanced for quality and speed
negative_prompt="blurry, low quality, distorted, ugly, deformed, bad anatomy",
).images[0]
return image
except ImportError:
logger.warning(
"diffusers library not installed, falling back to placeholder"
)
return self._generate_placeholder(prompt, size)
except Exception as e:
logger.error(f"Local Stable Diffusion generation failed: {e}")
return self._generate_placeholder(prompt, size)
def _generate_placeholder(self, prompt, size):
"""
Generate a placeholder image when Stable Diffusion is not available
"""
logger.info("Generating placeholder image")
# Create a more sophisticated placeholder
img = Image.new("RGBA", size, color=(240, 248, 255, 255))
from PIL import ImageDraw, ImageFont
draw = ImageDraw.Draw(img)
try:
font = ImageFont.load_default()
except Exception: # pylint: disable=broad-except
font = None
# Add title
title = "AI Avatar (Stable Diffusion)"
draw.text((10, 10), title, fill="darkblue", font=font)
# Add prompt
prompt_text = f"Prompt: {prompt[:50]}..."
draw.text((10, 40), prompt_text, fill="black", font=font)
# Add note
note = "Install Stable Diffusion for real generation"
draw.text((10, 70), note, fill="darkgreen", font=font)
# Create a more sophisticated avatar placeholder
center_x, center_y = size[0] // 2, size[1] // 2 + 20
radius = min(size) // 4
# Face circle
draw.ellipse(
[
center_x - radius,
center_y - radius,
center_x + radius,
center_y + radius,
],
outline="purple",
width=3,
fill=(255, 240, 245),
)
# Eyes
eye_radius = radius // 4
draw.ellipse(
[
center_x - radius // 2 - eye_radius,
center_y - radius // 2 - eye_radius,
center_x - radius // 2 + eye_radius,
center_y - radius // 2 + eye_radius,
],
fill="blue",
)
draw.ellipse(
[
center_x + radius // 2 - eye_radius,
center_y - radius // 2 - eye_radius,
center_x + radius // 2 + eye_radius,
center_y - radius // 2 + eye_radius,
],
fill="blue",
)
# Smile
smile_y = center_y + radius // 3
draw.arc(
[
center_x - radius // 2,
smile_y - radius // 4,
center_x + radius // 2,
smile_y + radius // 4,
],
0,
180,
fill="red",
width=3,
)
return img
def validate_avatar_prompt(prompt, model="stable_diffusion"):
"""
Convenience function to validate avatar prompts
Args:
prompt (str): Text description of the avatar
model (str): AI model to use
Returns:
dict: Validation result with 'valid', 'token_count', 'limit', 'warning'
"""
if model == "stable_diffusion":
service = StableDiffusionService()
return service.validate_prompt(prompt, model)
else:
# For other models, assume they're valid
return {"valid": True, "token_count": 0, "limit": 0, "warning": None}
def generate_avatar_image(
prompt,
model="stable_diffusion",
size=(512, 512),
quality="medium",
allow_nsfw=False,
):
"""
Convenience function to generate avatar images
Args:
prompt (str): Text description of the avatar
model (str): AI model to use (currently only 'stable_diffusion')
size (tuple): Image dimensions
quality (str): Generation quality ('low', 'medium', 'high')
allow_nsfw (bool): Whether to allow potentially NSFW content
Returns:
PIL.Image: Generated avatar image
"""
if model == "stable_diffusion":
service = StableDiffusionService()
return service.generate_image(prompt, size, quality, allow_nsfw)
else:
raise AIServiceError(
f"Unsupported model: {model}. Only 'stable_diffusion' is currently supported."
)

56
ivatar/celery.py Normal file
View File

@@ -0,0 +1,56 @@
# -*- coding: utf-8 -*-
"""
Celery configuration for ivatar
"""
import os
from celery import Celery
from django.conf import settings
# Set the default Django settings module for the 'celery' program.
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "ivatar.settings")
app = Celery("ivatar")
# Using a string here means the worker doesn't have to serialize
# the configuration object to child processes.
app.config_from_object("django.conf:settings", namespace="CELERY")
# Load task modules from all registered Django apps.
app.autodiscover_tasks()
# Celery configuration
app.conf.update(
# Task routing - use default queue for simplicity
task_default_queue="default",
task_routes={
"ivatar.tasks.generate_avatar_task": {"queue": "default"},
"ivatar.tasks.update_queue_positions": {"queue": "default"},
"ivatar.tasks.cleanup_old_tasks": {"queue": "default"},
},
# Worker configuration
worker_prefetch_multiplier=1,
task_acks_late=True,
# Result backend
result_backend="django-db",
result_expires=3600, # 1 hour
# Task time limits
task_time_limit=300, # 5 minutes
task_soft_time_limit=240, # 4 minutes
# Task serialization
task_serializer="json",
accept_content=["json"],
result_serializer="json",
# Timezone
timezone="UTC",
enable_utc=True,
)
# Set worker concurrency from Django settings
if hasattr(settings, "CELERY_WORKER_CONCURRENCY"):
app.conf.worker_concurrency = settings.CELERY_WORKER_CONCURRENCY
@app.task(bind=True)
def debug_task(self):
print(f"Request: {self.request!r}")

View File

@@ -3,7 +3,7 @@
Default: useful variables for the base page templates.
"""
from ipware import get_client_ip
from ipware import get_client_ip # type: ignore
from ivatar.settings import IVATAR_VERSION, SITE_NAME, MAX_PHOTO_SIZE
from ivatar.settings import BASE_URL, SECURE_BASE_URL
from ivatar.settings import MAX_NUM_UNCONFIRMED_EMAILS
@@ -28,6 +28,7 @@ def basepage(request):
context["BASE_URL"] = BASE_URL
context["SECURE_BASE_URL"] = SECURE_BASE_URL
context["max_emails"] = False
if request.user:
if not request.user.is_anonymous:
unconfirmed = request.user.unconfirmedemail_set.count()

View File

@@ -0,0 +1,56 @@
# -*- coding: utf-8 -*-
from social_core.backends.open_id_connect import OpenIdConnectAuth
from ivatar.ivataraccount.models import ConfirmedEmail, Photo
from ivatar.settings import logger, TRUST_EMAIL_FROM_SOCIAL_AUTH_BACKENDS
class FedoraOpenIdConnect(OpenIdConnectAuth):
name = "fedora"
USERNAME_KEY = "nickname"
OIDC_ENDPOINT = "https://id.fedoraproject.org"
DEFAULT_SCOPE = ["openid", "profile", "email"]
TOKEN_ENDPOINT_AUTH_METHOD = "client_secret_post"
# Pipeline methods
def add_confirmed_email(backend, user, response, *args, **kwargs):
"""Add a ConfirmedEmail if we trust the auth backend to validate email."""
if not kwargs.get("is_new", False):
return None # Only act on account creation
if backend.name not in TRUST_EMAIL_FROM_SOCIAL_AUTH_BACKENDS:
return None
if ConfirmedEmail.objects.filter(email=user.email).count() > 0:
# email already exists
return None
(confirmed_id, external_photos) = ConfirmedEmail.objects.create_confirmed_email(
user, user.email, True
)
confirmed_email = ConfirmedEmail.objects.get(id=confirmed_id)
logger.debug(
"Email %s added upon creation of user %s", confirmed_email.email, user.pk
)
photo = Photo.objects.create(user=user, ip_address=confirmed_email.ip_address)
import_result = photo.import_image("Gravatar", confirmed_email.email)
if import_result:
logger.debug("Gravatar image imported for %s", confirmed_email.email)
def associate_by_confirmed_email(backend, details, user=None, *args, **kwargs):
"""
Associate current auth with a user that has their email address as ConfirmedEmail in the DB.
"""
if user:
return None
email = details.get("email")
if not email:
return None
try:
confirmed_email = ConfirmedEmail.objects.get(email=email)
except ConfirmedEmail.DoesNotExist:
return None
user = confirmed_email.user
logger.debug("Found a matching ConfirmedEmail for %s upon login", user.username)
return {"user": user, "is_new": False}

View File

@@ -118,9 +118,7 @@ class UploadPhotoForm(forms.Form):
photo.ip_address = get_client_ip(request)[0]
photo.data = data.read()
photo.save()
if not photo.pk:
return None
return photo
return photo if photo.pk else None
class AddOpenIDForm(forms.Form):
@@ -141,12 +139,15 @@ class AddOpenIDForm(forms.Form):
"""
# Lowercase hostname port of the URL
url = urlsplit(self.cleaned_data["openid"])
data = urlunsplit(
(url.scheme.lower(), url.netloc.lower(), url.path, url.query, url.fragment)
return urlunsplit(
(
url.scheme.lower(),
url.netloc.lower(),
url.path,
url.query,
url.fragment,
)
)
# TODO: Domain restriction as in libravatar?
return data
def save(self, user):
"""
@@ -216,6 +217,89 @@ class UploadLibravatarExportForm(forms.Form):
)
class GenerateAvatarForm(forms.Form):
"""
Form for generating avatars using AI text-to-image
"""
MODEL_CHOICES = [
("stable_diffusion", "Stable Diffusion"),
# Future models can be added here
]
prompt = forms.CharField(
label=_("Avatar Description"),
max_length=500,
widget=forms.Textarea(
attrs={
"rows": 3,
"placeholder": _(
'Describe the avatar you want to create, e.g., "A friendly robot with blue eyes"'
),
"id": "id_prompt",
"data-token-limit": "77",
"data-model": "stable_diffusion",
}
),
help_text=_(
"Describe the avatar you want to generate. Be specific about appearance, style, and mood.<br><small class='text-muted'>Stable Diffusion has a 77-token limit. Keep your description concise for best results.</small>"
),
)
model = forms.ChoiceField(
label=_("AI Model"),
choices=MODEL_CHOICES,
initial="stable_diffusion",
help_text=_("Select the AI model to use for generation."),
)
quality = forms.ChoiceField(
label=_("Generation Quality"),
choices=[
("low", _("Low (faster, lower quality)")),
("medium", _("Medium (balanced)")),
("high", _("High (slower, better quality)")),
],
initial="medium",
help_text=_("Higher quality takes longer but produces better results."),
)
not_porn = forms.BooleanField(
label=_("Suitable for all ages (no offensive content)"),
required=True,
error_messages={
"required": _(
'We only host "G-rated" images and so this field must be checked.'
)
},
)
can_distribute = forms.BooleanField(
label=_("Can be freely copied"),
required=True,
error_messages={
"required": _(
"This field must be checked since we need to be able to distribute photos to third parties."
)
},
)
def clean_prompt(self):
"""Validate prompt length against token limits"""
prompt = self.cleaned_data.get("prompt", "")
model = self.cleaned_data.get("model", "stable_diffusion")
if prompt:
from ivatar.ai_service import validate_avatar_prompt
validation = validate_avatar_prompt(prompt, model)
if not validation["valid"]:
raise forms.ValidationError(validation["warning"])
return prompt
class DeleteAccountForm(forms.Form):
password = forms.CharField(
label=_("Password"), required=False, widget=forms.PasswordInput()

View File

@@ -3,13 +3,12 @@
Helper method to fetch Gravatar image
"""
from ssl import SSLError
from urllib.request import urlopen, HTTPError, URLError
from urllib.request import HTTPError, URLError
from ivatar.utils import urlopen
import hashlib
from ..settings import AVATAR_MAX_SIZE
URL_TIMEOUT = 5 # in seconds
def get_photo(email):
"""
@@ -23,29 +22,23 @@ def get_photo(email):
+ "?s=%i&d=404" % AVATAR_MAX_SIZE
)
image_url = (
"https://secure.gravatar.com/avatar/" + hash_object.hexdigest() + "?s=512&d=404"
f"https://secure.gravatar.com/avatar/{hash_object.hexdigest()}?s=512&d=404"
)
# Will redirect to the public profile URL if it exists
service_url = "http://www.gravatar.com/" + hash_object.hexdigest()
service_url = f"http://www.gravatar.com/{hash_object.hexdigest()}"
try:
urlopen(image_url, timeout=URL_TIMEOUT)
urlopen(image_url)
except HTTPError as exc:
if exc.code != 404 and exc.code != 503:
print( # pragma: no cover
"Gravatar fetch failed with an unexpected %s HTTP error" % exc.code
)
if exc.code not in [404, 503]:
print(f"Gravatar fetch failed with an unexpected {exc.code} HTTP error")
return False
except URLError as exc: # pragma: no cover
print(
"Gravatar fetch failed with URL error: %s" % exc.reason
) # pragma: no cover
print(f"Gravatar fetch failed with URL error: {exc.reason}")
return False # pragma: no cover
except SSLError as exc: # pragma: no cover
print(
"Gravatar fetch failed with SSL error: %s" % exc.reason
) # pragma: no cover
print(f"Gravatar fetch failed with SSL error: {exc.reason}")
return False # pragma: no cover
return {

View File

@@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
# Generated by Django 5.0 on 2024-05-31 15:00
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("ivataraccount", "0017_auto_20210528_1314"),
]
operations = [
migrations.AlterField(
model_name="photo",
name="format",
field=models.CharField(max_length=4),
),
]

View File

@@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
# Generated by Django 5.1.5 on 2025-01-27 10:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("ivataraccount", "0018_alter_photo_format"),
]
operations = [
migrations.AddField(
model_name="confirmedemail",
name="bluesky_handle",
field=models.CharField(blank=True, max_length=256, null=True),
),
]

View File

@@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
# Generated by Django 5.1.5 on 2025-01-27 13:33
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("ivataraccount", "0019_confirmedemail_bluesky_handle"),
]
operations = [
migrations.AddField(
model_name="confirmedopenid",
name="bluesky_handle",
field=models.CharField(blank=True, max_length=256, null=True),
),
]

View File

@@ -0,0 +1,50 @@
# -*- coding: utf-8 -*-
# Generated by Django 5.2.1 on 2025-09-17 10:06
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("ivataraccount", "0020_confirmedopenid_bluesky_handle"),
]
operations = [
migrations.AddField(
model_name="photo",
name="ai_generated",
field=models.BooleanField(
default=False, help_text="Whether this photo was generated by AI"
),
),
migrations.AddField(
model_name="photo",
name="ai_model",
field=models.CharField(
blank=True,
help_text="The AI model used for generation",
max_length=50,
null=True,
),
),
migrations.AddField(
model_name="photo",
name="ai_prompt",
field=models.TextField(
blank=True,
help_text="The prompt used to generate this image",
null=True,
),
),
migrations.AddField(
model_name="photo",
name="ai_quality",
field=models.CharField(
blank=True,
help_text="The quality setting used",
max_length=20,
null=True,
),
),
]

View File

@@ -0,0 +1,80 @@
# -*- coding: utf-8 -*-
# Generated by Django 5.2.1 on 2025-09-17 10:25
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("ivataraccount", "0021_add_ai_generation_fields"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="GenerationTask",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"ip_address",
models.GenericIPAddressField(null=True, unpack_ipv4=True),
),
("add_date", models.DateTimeField(default=django.utils.timezone.now)),
("prompt", models.TextField()),
("model", models.CharField(max_length=50)),
("quality", models.CharField(max_length=20)),
("allow_nsfw", models.BooleanField(default=False)),
(
"status",
models.CharField(
choices=[
("pending", "Pending"),
("processing", "Processing"),
("completed", "Completed"),
("failed", "Failed"),
("cancelled", "Cancelled"),
],
default="pending",
max_length=20,
),
),
("progress", models.IntegerField(default=0)),
("queue_position", models.IntegerField(default=0)),
("task_id", models.CharField(blank=True, max_length=255, null=True)),
("error_message", models.TextField(blank=True, null=True)),
(
"generated_photo",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="ivataraccount.photo",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "Generation Task",
"verbose_name_plural": "Generation Tasks",
"ordering": ["-add_date"],
},
),
]

View File

@@ -0,0 +1,22 @@
# -*- coding: utf-8 -*-
# Generated by Django 5.2.1 on 2025-09-17 10:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("ivataraccount", "0022_add_generation_task"),
]
operations = [
migrations.AddField(
model_name="photo",
name="ai_invalid",
field=models.BooleanField(
default=False,
help_text="Whether this AI-generated image is invalid (black, etc.)",
),
),
]

View File

@@ -9,8 +9,8 @@ import time
from io import BytesIO
from os import urandom
from urllib.error import HTTPError, URLError
from urllib.request import urlopen
from urllib.parse import urlsplit, urlunsplit
from ivatar.utils import urlopen, Bluesky
from urllib.parse import urlsplit, urlunsplit, quote
from PIL import Image
from django.contrib.auth.models import User
@@ -20,6 +20,7 @@ from django.utils import timezone
from django.http import HttpResponseRedirect
from django.urls import reverse_lazy, reverse
from django.utils.translation import gettext_lazy as _
from django.core.cache import cache
from django.core.exceptions import ObjectDoesNotExist
from django.core.mail import send_mail
from django.template.loader import render_to_string
@@ -41,12 +42,14 @@ def file_format(image_type):
"""
Helper method returning a short image type
"""
if image_type == "JPEG":
if image_type in ("JPEG", "MPO"):
return "jpg"
elif image_type == "PNG":
return "png"
elif image_type == "GIF":
return "gif"
elif image_type == "WEBP":
return "webp"
return None
@@ -54,12 +57,14 @@ def pil_format(image_type):
"""
Helper method returning the 'encoder name' for PIL
"""
if image_type == "jpg" or image_type == "jpeg":
if image_type in ("jpg", "jpeg", "mpo"):
return "JPEG"
elif image_type == "png":
return "PNG"
elif image_type == "gif":
return "GIF"
elif image_type == "webp":
return "WEBP"
logger.info("Unsupported file format: %s", image_type)
return None
@@ -113,6 +118,42 @@ class BaseAccountModel(models.Model):
abstract = True
class GenerationTask(BaseAccountModel):
"""
Model to track avatar generation tasks in the queue
"""
STATUS_CHOICES = [
("pending", _("Pending")),
("processing", _("Processing")),
("completed", _("Completed")),
("failed", _("Failed")),
("cancelled", _("Cancelled")),
]
user = models.ForeignKey(User, on_delete=models.CASCADE)
prompt = models.TextField()
model = models.CharField(max_length=50)
quality = models.CharField(max_length=20)
allow_nsfw = models.BooleanField(default=False)
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="pending")
progress = models.IntegerField(default=0) # 0-100
queue_position = models.IntegerField(default=0)
task_id = models.CharField(max_length=255, blank=True, null=True) # Celery task ID
error_message = models.TextField(blank=True, null=True)
generated_photo = models.ForeignKey(
"Photo", on_delete=models.SET_NULL, blank=True, null=True
)
class Meta:
ordering = ["-add_date"]
verbose_name = _("Generation Task")
verbose_name_plural = _("Generation Tasks")
def __str__(self):
return f"Task {self.pk}: {self.prompt[:50]}... ({self.status})"
class Photo(BaseAccountModel):
"""
Model holding the photos and information about them
@@ -120,9 +161,30 @@ class Photo(BaseAccountModel):
ip_address = models.GenericIPAddressField(unpack_ipv4=True)
data = models.BinaryField()
format = models.CharField(max_length=3)
format = models.CharField(max_length=4)
access_count = models.BigIntegerField(default=0, editable=False)
# AI Generation metadata
ai_generated = models.BooleanField(
default=False, help_text=_("Whether this photo was generated by AI")
)
ai_prompt = models.TextField(
blank=True, null=True, help_text=_("The prompt used to generate this image")
)
ai_model = models.CharField(
max_length=50,
blank=True,
null=True,
help_text=_("The AI model used for generation"),
)
ai_quality = models.CharField(
max_length=20, blank=True, null=True, help_text=_("The quality setting used")
)
ai_invalid = models.BooleanField(
default=False,
help_text=_("Whether this AI-generated image is invalid (black, etc.)"),
)
class Meta: # pylint: disable=too-few-public-methods
"""
Class attributes
@@ -131,6 +193,49 @@ class Photo(BaseAccountModel):
verbose_name = _("photo")
verbose_name_plural = _("photos")
def is_valid_avatar(self):
"""
Check if this photo is a valid avatar (not black/invalid)
"""
if not self.ai_generated:
return True # Non-AI photos are assumed valid
# If we've already marked it as invalid, return False
if self.ai_invalid:
return False
try:
from PIL import Image
import io
# Load the image data
image_data = io.BytesIO(self.data)
image = Image.open(image_data)
# Convert to RGB if needed
if image.mode != "RGB":
image = image.convert("RGB")
# Check if image is predominantly black (common NSFW response)
pixels = list(image.getdata())
black_pixels = sum(1 for r, g, b in pixels if r == 0 and g == 0 and b == 0)
total_pixels = len(pixels)
# If more than 95% black pixels, consider it invalid
black_ratio = black_pixels / total_pixels
is_valid = black_ratio < 0.95
# Cache the result
if not is_valid:
self.ai_invalid = True
self.save(update_fields=["ai_invalid"])
return is_valid
except Exception:
# If we can't analyze the image, assume it's valid
return True
def import_image(self, service_name, email_address):
"""
Allow to import image from other (eg. Gravatar) service
@@ -138,8 +243,7 @@ class Photo(BaseAccountModel):
image_url = False
if service_name == "Gravatar":
gravatar = get_gravatar_photo(email_address)
if gravatar:
if gravatar := get_gravatar_photo(email_address):
image_url = gravatar["image_url"]
if service_name == "Libravatar":
@@ -149,15 +253,11 @@ class Photo(BaseAccountModel):
return False # pragma: no cover
try:
image = urlopen(image_url)
# No idea how to test this
# pragma: no cover
except HTTPError as exc:
print("%s import failed with an HTTP error: %s" % (service_name, exc.code))
print(f"{service_name} import failed with an HTTP error: {exc.code}")
return False
# No idea how to test this
# pragma: no cover
except URLError as exc:
print("%s import failed: %s" % (service_name, exc.reason))
print(f"{service_name} import failed: {exc.reason}")
return False
data = image.read()
@@ -169,7 +269,7 @@ class Photo(BaseAccountModel):
self.format = file_format(img.format)
if not self.format:
print("Unable to determine format: %s" % img) # pragma: no cover
print(f"Unable to determine format: {img}")
return False # pragma: no cover
self.data = data
super().save()
@@ -184,10 +284,9 @@ class Photo(BaseAccountModel):
# Use PIL to read the file format
try:
img = Image.open(BytesIO(self.data))
# Testing? Ideas anyone?
except Exception as exc: # pylint: disable=broad-except
# For debugging only
print("Exception caught in Photo.save(): %s" % exc)
print(f"Exception caught in Photo.save(): {exc}")
return False
self.format = file_format(img.format)
if not self.format:
@@ -262,7 +361,7 @@ class Photo(BaseAccountModel):
cropped_w, cropped_h = cropped.size
max_w = AVATAR_MAX_SIZE
if cropped_w > max_w or cropped_h > max_w:
cropped = cropped.resize((max_w, max_w), Image.ANTIALIAS)
cropped = cropped.resize((max_w, max_w), Image.LANCZOS)
data = BytesIO()
cropped.save(data, pil_format(self.format), quality=JPEG_QUALITY)
@@ -297,8 +396,7 @@ class ConfirmedEmailManager(models.Manager):
external_photos = []
if is_logged_in:
gravatar = get_gravatar_photo(confirmed.email)
if gravatar:
if gravatar := get_gravatar_photo(confirmed.email):
external_photos.append(gravatar)
return (confirmed.pk, external_photos)
@@ -318,6 +416,8 @@ class ConfirmedEmail(BaseAccountModel):
null=True,
on_delete=models.deletion.SET_NULL,
)
# Alternative assignment - use Bluesky handle
bluesky_handle = models.CharField(max_length=256, null=True, blank=True)
digest = models.CharField(max_length=32)
digest_sha256 = models.CharField(max_length=64)
objects = ConfirmedEmailManager()
@@ -338,6 +438,19 @@ class ConfirmedEmail(BaseAccountModel):
self.photo = photo
self.save()
def set_bluesky_handle(self, handle):
"""
Helper method to set Bluesky handle
"""
bs = Bluesky()
handle = bs.normalize_handle(handle)
avatar = bs.get_profile(handle)
if not avatar:
raise ValueError("Invalid Bluesky handle")
self.bluesky_handle = handle
self.save()
def save(
self, force_insert=False, force_update=False, using=None, update_fields=None
):
@@ -350,6 +463,20 @@ class ConfirmedEmail(BaseAccountModel):
self.digest_sha256 = hashlib.sha256(
self.email.strip().lower().encode("utf-8")
).hexdigest()
# We need to manually expire the page caches
# TODO: Verify this works as expected
# First check if we already have an ID
if self.pk:
cache_url = reverse_lazy(
"assign_photo_email", kwargs={"email_id": int(self.pk)}
)
cache_key = f"views.decorators.cache.cache_page.{quote(str(cache_url))}"
if cache.has_key(cache_key):
cache.delete(cache_key)
logger.debug("Successfully cleaned up cached page: %s" % cache_key)
return super().save(force_insert, force_update, using, update_fields)
def __str__(self):
@@ -410,7 +537,7 @@ class UnconfirmedEmail(BaseAccountModel):
try:
send_mail(email_subject, email_body, DEFAULT_FROM_EMAIL, [self.email])
except Exception as e:
self.last_status = "%s" % e
self.last_status = f"{e}"
self.save()
return True
@@ -459,6 +586,8 @@ class ConfirmedOpenId(BaseAccountModel):
alt_digest2 = models.CharField(max_length=64, null=True, blank=True, default=None)
# https://<id> - https w/o trailing slash
alt_digest3 = models.CharField(max_length=64, null=True, blank=True, default=None)
# Alternative assignment - use Bluesky handle
bluesky_handle = models.CharField(max_length=256, null=True, blank=True)
access_count = models.BigIntegerField(default=0, editable=False)
@@ -477,13 +606,25 @@ class ConfirmedOpenId(BaseAccountModel):
self.photo = photo
self.save()
def set_bluesky_handle(self, handle):
"""
Helper method to set Bluesky handle
"""
bs = Bluesky()
handle = bs.normalize_handle(handle)
avatar = bs.get_profile(handle)
if not avatar:
raise ValueError("Invalid Bluesky handle")
self.bluesky_handle = handle
self.save()
def save(
self, force_insert=False, force_update=False, using=None, update_fields=None
):
url = urlsplit(self.openid)
if url.username: # pragma: no cover
password = url.password or ""
netloc = url.username + ":" + password + "@" + url.hostname
netloc = f"{url.username}:{password}@{url.hostname}"
else:
netloc = url.hostname
lowercase_url = urlunsplit(
@@ -603,9 +744,7 @@ class DjangoOpenIDStore(OpenIDStore):
self.removeAssociation(server_url, assoc.handle)
else:
associations.append((association.issued, association))
if not associations:
return None
return associations[-1][1]
return associations[-1][1] if associations else None
@staticmethod
def removeAssociation(server_url, handle): # pragma: no cover
@@ -658,6 +797,6 @@ class DjangoOpenIDStore(OpenIDStore):
"""
Helper method to cleanup associations
"""
OpenIDAssociation.objects.extra( # pylint: disable=no-member
where=["issued + lifetimeint < (%s)" % time.time()]
OpenIDAssociation.objects.extra(
where=[f"issued + lifetimeint < ({time.time()})"]
).delete()

View File

@@ -32,8 +32,6 @@ def read_gzdata(gzdata=None):
"""
Read gzipped data file
"""
emails = [] # pylint: disable=invalid-name
openids = [] # pylint: disable=invalid-name
photos = [] # pylint: disable=invalid-name
username = None # pylint: disable=invalid-name
password = None # pylint: disable=invalid-name
@@ -45,8 +43,8 @@ def read_gzdata(gzdata=None):
content = fh.read()
fh.close()
root = xml.etree.ElementTree.fromstring(content)
if not root.tag == "{%s}user" % SCHEMAROOT:
print("Unknown export format: %s" % root.tag)
if root.tag != "{%s}user" % SCHEMAROOT:
print(f"Unknown export format: {root.tag}")
exit(-1)
# Username
@@ -56,23 +54,21 @@ def read_gzdata(gzdata=None):
if item[0] == "password":
password = item[1]
# Emails
for email in root.findall("{%s}emails" % SCHEMAROOT)[0]:
if email.tag == "{%s}email" % SCHEMAROOT:
emails.append({"email": email.text, "photo_id": email.attrib["photo_id"]})
# OpenIDs
for openid in root.findall("{%s}openids" % SCHEMAROOT)[0]:
if openid.tag == "{%s}openid" % SCHEMAROOT:
openids.append(
emails = [
{"email": email.text, "photo_id": email.attrib["photo_id"]}
for email in root.findall("{%s}emails" % SCHEMAROOT)[0]
if email.tag == "{%s}email" % SCHEMAROOT
]
openids = [
{"openid": openid.text, "photo_id": openid.attrib["photo_id"]}
)
for openid in root.findall("{%s}openids" % SCHEMAROOT)[0]
if openid.tag == "{%s}openid" % SCHEMAROOT
]
# Photos
for photo in root.findall("{%s}photos" % SCHEMAROOT)[0]:
if photo.tag == "{%s}photo" % SCHEMAROOT:
try:
# Safty measures to make sure we do not try to parse
# Safety measures to make sure we do not try to parse
# a binary encoded string
photo.text = photo.text.strip("'")
photo.text = photo.text.strip("\\n")
@@ -80,26 +76,14 @@ def read_gzdata(gzdata=None):
data = base64.decodebytes(bytes(photo.text, "utf-8"))
except binascii.Error as exc:
print(
"Cannot decode photo; Encoding: %s, Format: %s, Id: %s: %s"
% (
photo.attrib["encoding"],
photo.attrib["format"],
photo.attrib["id"],
exc,
)
f'Cannot decode photo; Encoding: {photo.attrib["encoding"]}, Format: {photo.attrib["format"]}, Id: {photo.attrib["id"]}: {exc}'
)
continue
try:
Image.open(BytesIO(data))
except Exception as exc: # pylint: disable=broad-except
print(
"Cannot decode photo; Encoding: %s, Format: %s, Id: %s: %s"
% (
photo.attrib["encoding"],
photo.attrib["format"],
photo.attrib["id"],
exc,
)
f'Cannot decode photo; Encoding: {photo.attrib["encoding"]}, Format: {photo.attrib["format"]}, Id: {photo.attrib["id"]}: {exc}'
)
continue
else:

View File

@@ -22,7 +22,8 @@
<a href="{{ photo.service_url }}" style="float:right;color:#FFFFFF"><i class="fa fa-external-link"></i></a>
{% endif %}
</label>
</h3></div>
</h3>
</div>
<div class="panel-body">
<center>
<img src="{{ photo.thumbnail_url }}" style="max-width: 80px; max-height: 80px;" alt="{{ photo.service_name }} image">

View File

@@ -17,15 +17,17 @@
{% if form.email.errors %}
<div class="alert alert-danger" role="alert">{{ form.email.errors }}</div>
{% endif %}
<div style="max-width:640px">
<div class="form-container">
<form action="{% url 'add_email' %}" name="addemail" method="post" id="form-addemail">
{% csrf_token %}
<div class="form-group">
<label for="id_email">{% trans 'Email' %}:</label>
<input type="text" name="email" autofocus required class="form-control" id="id_email">
<label for="id_email" class="form-label">{% trans 'Email' %}</label>
<input type="email" name="email" autofocus required class="form-control" id="id_email" placeholder="{% trans 'Enter your email address' %}">
</div>
<div class="button-group">
<button type="submit" class="btn btn-primary">{% trans 'Add' %}</button>
</div>
<button type="submit" class="button">{% trans 'Add' %}</button>
</form>
</div>

View File

@@ -4,37 +4,18 @@
{% block title %}{% blocktrans with email.email as email_address %}Choose a photo for {{ email_address }}{% endblocktrans %}{% endblock title %}
{% block content %}
<style>
.nobutton {
background: none;
color: inherit;
border: none;
padding: 0;
font: inherit;
cursor: pointer;
outline: inherit;
}
</style>
<h1>{% blocktrans with email.email as email_address %}Choose a photo for {{ email_address }}{% endblocktrans %}</h1>
{% if not user.photo_set.count %}
{% url 'upload_photo' as upload_url %}
<h4>{% blocktrans %}You need to <a href="{{ upload_url }}">upload some photos</a> first!{% endblocktrans %}</h4>
<p><a href="{% url 'profile' %}" class="button">{% trans 'Back to your profile' %}</a></p>
{% else %}
{% if user.photo_set.count %}
<p>{% trans 'Here are the pictures you have uploaded, click on the one you wish to associate with this email address:' %}</p>
<div class="row">
<div class="photo-grid">
{% for photo in user.photo_set.all %}
<form action="{% url 'assign_photo_email' view.kwargs.email_id %}" method="post" style="float:left;margin-left:20px">{% csrf_token %}
<form action="{% url 'assign_photo_email' view.kwargs.email_id %}" method="post" class="photo-card">{% csrf_token %}
<input type="hidden" name="photo_id" value="{{ photo.id }}">
<button type="submit" name="photo{{ photo.id }}" class="nobutton">
<div class="panel panel-tortin" style="width:132px;margin:0">
<div class="panel panel-tortin">
<div class="panel-heading">
<h3 class="panel-title">{% ifequal email.photo.id photo.id %}<i class="fa fa-check"></i>{% endifequal %} {% trans 'Image' %} {{ forloop.counter }}</h3>
<h3 class="panel-title">{% if email.photo.id == photo.id %}<i class="fa fa-check"></i>{% endif %} {% trans 'Image' %} {{ forloop.counter }}</h3>
</div>
<div class="panel-body" style="height:130px">
<center>
@@ -45,11 +26,15 @@ outline: inherit;
</button>
</form>
{% endfor %}
<form action="{% url 'assign_photo_email' view.kwargs.email_id %}" method="post" style="float:left;margin-left:20px">{% csrf_token %}
</div>
{% endif %}
<div class="photo-grid">
<form action="{% url 'assign_photo_email' view.kwargs.email_id %}" method="post" class="photo-card">{% csrf_token %}
<button type="submit" name="photoNone" class="nobutton">
<div class="panel panel-tortin" style="width:132px;margin:0">
<div class="panel panel-tortin">
<div class="panel-heading">
<h3 class="panel-title">{% ifequal email.photo.id photo.id %}<i class="fa fa-check"></i>{% endifequal %} {% trans 'No image' %}</h3>
<h3 class="panel-title">{% if email.photo.id == photo.id %}{% if not email.bluesky_handle %}<i class="fa fa-check"></i>{% endif %}{% endif %} {% trans 'No image' %}</h3>
</div>
<div class="panel-body" style="height:130px">
<center>
@@ -59,10 +44,41 @@ outline: inherit;
</div>
</button>
</form>
{% if email.bluesky_handle %}
<form action="{% url 'assign_photo_email' view.kwargs.email_id %}" method="post" class="photo-card">{% csrf_token %}
<input type="hidden" name="photo_id" value="bluesky">
<button type="submit" name="photoBluesky" class="nobutton">
<div class="panel panel-tortin">
<div class="panel-heading">
<h3 class="panel-title">{% if email.bluesky_handle %}<i class="fa fa-check"></i>{% endif %} {% trans "Bluesky" %}</h3>
</div>
<div style="height:8px"></div>
<a href="{% url 'upload_photo' %}" class="button">{% blocktrans %}Upload a new one{% endblocktrans %}</a>&nbsp;&nbsp;
<a href="{% url 'import_photo' email.pk %}" class="button">{% blocktrans %}Import from other services{% endblocktrans %}</a>
<div class="panel-body" style="height:130px">
<center>
<img style="max-height:100px;max-width:100px" src="{% url "blueskyproxy" email.digest %}?size=100">
</center>
</div>
</div>
</button>
</form>
{% endif %}
<div style="height:40px"></div>
</div>
<div class="action-buttons">
<a href="{% url 'upload_photo' %}" class="btn btn-primary">{% blocktrans %}Upload a new one{% endblocktrans %}</a>
<a href="{% url 'import_photo' %}" class="btn btn-secondary">{% blocktrans %}Import from other services{% endblocktrans %}</a>
</div>
<div style="margin-top: 2rem;">
<form action="{% url 'assign_bluesky_handle_to_email' view.kwargs.email_id %}" method="post">{% csrf_token %}
<div class="form-group">
<label for="id_bluesky_handle">{% trans "Bluesky handle" %}:</label>
{% if email.bluesky_handle %}
<input type="text" name="bluesky_handle" required value="{{ email.bluesky_handle }}" class="form-control" id="id_bluesky_handle">
{% else %}
<input type="text" name="bluesky_handle" required value="" placeholder="{% trans 'Bluesky handle' %}" class="form-control" id="id_bluesky_handle">
{% endif %}
</div>
<button type="submit" class="btn btn-primary">{% trans 'Assign Bluesky Handle' %}</button>
</form>
</div>
{% endblock content %}

View File

@@ -4,37 +4,18 @@
{% block title %}{% blocktrans with openid.openid as openid_address %}Choose a photo for {{ openid_address }}{% endblocktrans %}{% endblock title %}
{% block content %}
<style>
.nobutton {
background: none;
color: inherit;
border: none;
padding: 0;
font: inherit;
cursor: pointer;
outline: inherit;
}
</style>
<h1>{% blocktrans with openid.openid as openid_address %}Choose a photo for {{ openid_address }}{% endblocktrans %}</h1>
{% if not user.photo_set.count %}
{% url 'upload_photo' as upload_url %}
<h3>{% blocktrans %}You need to <a href="{{ upload_url }}">upload some photos</a> first!{% endblocktrans %}</h3>
<p><a href="{% url 'profile' %}" class="button">{% trans 'Back to your profile' %}</a></p>
{% else %}
{% if user.photo_set.count %}
<p>{% trans 'Here are the pictures you have uploaded, click on the one you wish to associate with this openid address:' %}</p>
<div class="row">
<div class="photo-grid">
{% for photo in user.photo_set.all %}
<form action="{% url 'assign_photo_openid' view.kwargs.openid_id %}" method="post" style="float:left;margin-left:20px">{% csrf_token %}
<form action="{% url 'assign_photo_openid' view.kwargs.openid_id %}" method="post" class="photo-card">{% csrf_token %}
<input type="hidden" name="photo_id" value="{{ photo.id }}">
<button type="submit" name="photo{{ photo.id }}" class="nobutton">
<div class="panel panel-tortin" style="width:132px;margin:0">
<div class="panel panel-tortin">
<div class="panel-heading">
<h3 class="panel-title">{% ifequal openid.photo.id photo.id %}<i class="fa fa-check"></i>{% endifequal %} {% trans 'Image' %} {{ forloop.counter }}</h3>
<h3 class="panel-title">{% if openid.photo.id == photo.id %}<i class="fa fa-check"></i>{% endif %} {% trans 'Image' %} {{ forloop.counter }}</h3>
</div>
<div class="panel-body" style="height:130px">
<center>
@@ -45,11 +26,15 @@ outline: inherit;
</button>
</form>
{% endfor %}
<form action="{% url 'assign_photo_openid' view.kwargs.openid_id %}" method="post" style="float:left;margin-left:20px">{% csrf_token %}
</div>
{% endif %}
<div class="photo-grid">
<form action="{% url 'assign_photo_openid' view.kwargs.openid_id %}" method="post" class="photo-card">{% csrf_token %}
<button type="submit" name="photoNone" class="nobutton">
<div class="panel panel-tortin" style="width:132px;margin:0">
<div class="panel panel-tortin">
<div class="panel-heading">
<h3 class="panel-title">{% ifequal openid.photo.id photo.id %}<i class="fa fa-check"></i>{% endifequal %} {% trans 'No image' %}</h3>
<h3 class="panel-title">{% if not openid.photo and not openid.bluesky_handle %}<i class="fa fa-check"></i>{% endif %} {% trans 'No image' %}</h3>
</div>
<div class="panel-body" style="height:130px">
<center>
@@ -59,10 +44,38 @@ outline: inherit;
</div>
</button>
</form>
{% if openid.bluesky_handle %}
<form action="" class="photo-card">
<div class="panel panel-tortin">
<div class="panel-heading">
<h3 class="panel-title"><i class="fa fa-check"></i> {% trans "Bluesky" %}</h3>
</div>
<div style="height:8px"></div>
<a href="{% url 'upload_photo' %}" class="button">{% blocktrans %}upload a new one{% endblocktrans %}</a>
<div class="panel-body" style="height:130px">
<center>
<img style="max-height:100px;max-width:100px" src="{% url "blueskyproxy" openid.digest %}?size=100">
</center>
</div>
</div>
</form>
{% endif %}
<div style="height:40px"></div>
</div>
<div class="action-buttons">
<a href="{% url 'upload_photo' %}" class="btn btn-primary">{% blocktrans %}upload a new one{% endblocktrans %}</a>
<a href="{% url 'import_photo' %}" class="btn btn-secondary">{% blocktrans %}Import from other services{% endblocktrans %}</a>
</div>
<div style="margin-top: 2rem;">
<form action="{% url 'assign_bluesky_handle_to_openid' view.kwargs.openid_id %}" method="post">{% csrf_token %}
<div class="form-group">
<label for="id_bluesky_handle">{% trans "Bluesky handle" %}:</label>
{% if openid.bluesky_handle %}
<input type="text" name="bluesky_handle" required value="{{ openid.bluesky_handle }}" class="form-control" id="id_bluesky_handle">
{% else %}
<input type="text" name="bluesky_handle" required value="" placeholder="{% trans 'Bluesky handle' %}" class="form-control" id="id_bluesky_handle">
{% endif %}
</div>
<button type="submit" class="btn btn-primary">{% trans 'Assign Bluesky Handle' %}</button>
</form>
</div>
{% endblock content %}

View File

@@ -0,0 +1,318 @@
{% extends 'base.html' %}
{% load i18n %}
{% load static %}
{% load bootstrap4 %}
{% block title %}{% trans 'Avatar Gallery' %}{% endblock %}
{% block extra_css %}
<style>
.avatar-gallery-card {
transition: all 0.3s ease;
border: 1px solid #e9ecef;
}
.avatar-gallery-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 25px rgba(0,0,0,0.15);
border-color: #007bff;
}
.avatar-image-container {
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
border-radius: 8px;
padding: 10px;
margin-bottom: 10px;
}
.avatar-image {
border: 2px solid #fff;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.gallery-section {
margin-bottom: 3rem;
}
.gallery-section h4 {
color: #495057;
border-bottom: 2px solid #007bff;
padding-bottom: 10px;
margin-bottom: 20px;
}
/* Fix button display issues - override base CSS */
.btn {
display: inline-block !important;
padding: 0.375rem 0.75rem !important;
margin-bottom: 0;
font-size: 1rem;
font-weight: 400;
line-height: 1.5;
text-align: center;
text-decoration: none;
vertical-align: middle;
cursor: pointer;
border: 1px solid transparent;
border-radius: 0.25rem;
transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
min-width: auto !important;
}
.btn-sm {
padding: 0.25rem 0.5rem !important;
font-size: 0.875rem;
border-radius: 0.2rem;
min-width: auto !important;
}
.btn-outline-primary {
color: #007bff !important;
border-color: #007bff !important;
background-color: transparent !important;
}
.btn-outline-primary:hover {
color: #fff !important;
background-color: #007bff !important;
border-color: #007bff !important;
}
.btn-outline-secondary {
color: #6c757d !important;
border-color: #6c757d !important;
background-color: transparent !important;
}
.btn-outline-secondary:hover {
color: #fff !important;
background-color: #6c757d !important;
border-color: #6c757d !important;
}
/* Ensure FontAwesome icons display */
.fa {
font-family: "FontAwesome" !important;
font-weight: normal;
display: inline-block;
font-style: normal;
font-variant: normal;
text-rendering: auto;
line-height: 1;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Suppress Firefox font warnings */
@font-face {
font-family: "FontAwesome";
src: url("../fonts/fontawesome-webfont.woff2?v=4.7.0") format("woff2"),
url("../fonts/fontawesome-webfont.woff?v=4.7.0") format("woff"),
url("../fonts/fontawesome-webfont.ttf?v=4.7.0") format("truetype"),
url("../fonts/fontawesome-webfont.svg?v=4.7.0#fontawesomeregular") format("svg");
font-weight: normal;
font-style: normal;
font-display: swap;
}
</style>
{% endblock %}
{% block content %}
<div class="container">
<div class="row">
<div class="col-md-12">
<h2>{% trans 'Avatar Gallery' %}</h2>
<p class="lead">
{% trans 'Browse recently generated avatars for inspiration. Click on any avatar to reuse its prompt.' %}
</p>
<!-- User's Own Avatars -->
{% if user_avatars %}
<div class="mb-4 gallery-section">
<h4><i class="fa fa-user"></i> {% trans 'Your Recent Avatars' %}</h4>
<div class="row">
{% for avatar in user_avatars %}
<div class="col-md-3 col-sm-4 col-6 mb-3">
<div class="card h-100 avatar-gallery-card">
<div class="card-body p-2">
<div class="avatar-image-container text-center d-flex justify-content-center align-items-center">
<img src="{% url 'raw_image' avatar.pk %}"
alt="Avatar"
class="img-fluid rounded avatar-image"
style="width: 120px; height: 120px; object-fit: contain;">
</div>
<div class="card-text">
<small class="text-muted">
{{ avatar.add_date|date:"M d, Y" }}
</small>
<p class="small mb-2" style="height: 40px; overflow: hidden;">
{{ avatar.ai_prompt|truncatechars:60 }}
</p>
<div class="d-flex justify-content-between">
<a href="{% url 'reuse_prompt' avatar.pk %}"
class="btn btn-sm btn-outline-primary">
<i class="fa fa-recycle"></i> {% trans 'Reuse' %}
</a>
<a href="{% url 'avatar_preview' avatar.pk %}"
class="btn btn-sm btn-outline-secondary">
<i class="fa fa-eye"></i> {% trans 'View' %}
</a>
</div>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Community Gallery -->
<div class="mb-4 gallery-section">
<h4><i class="fa fa-users"></i> {% trans 'Community Gallery' %}</h4>
<p class="text-muted">
{% trans 'Recently generated avatars from all users (last 30)' %}
</p>
{% if avatars %}
<div class="row">
{% for avatar in avatars %}
<div class="col-md-3 col-sm-4 col-6 mb-3">
<div class="card h-100 avatar-gallery-card">
<div class="card-body p-2">
<div class="avatar-image-container text-center d-flex justify-content-center align-items-center">
<img src="{% url 'raw_image' avatar.pk %}"
alt="Avatar"
class="img-fluid rounded avatar-image"
style="width: 120px; height: 120px; object-fit: contain;">
</div>
<div class="card-text">
<small class="text-muted">
{{ avatar.add_date|date:"M d, Y" }}
{% if avatar.user == user %}
<span class="badge badge-info">{% trans 'Yours' %}</span>
{% endif %}
</small>
<p class="small mb-2" style="height: 40px; overflow: hidden;">
{{ avatar.ai_prompt|truncatechars:60 }}
</p>
<div class="d-flex justify-content-between">
<a href="{% url 'reuse_prompt' avatar.pk %}"
class="btn btn-sm btn-outline-primary">
<i class="fa fa-recycle"></i> {% trans 'Reuse' %}
</a>
<a href="{% url 'avatar_preview' avatar.pk %}"
class="btn btn-sm btn-outline-secondary">
<i class="fa fa-eye"></i> {% trans 'View' %}
</a>
</div>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
<!-- Pagination -->
{% if is_paginated %}
<nav aria-label="Gallery pagination">
<ul class="pagination justify-content-center">
{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link" href="?page=1">{% trans 'First' %}</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.previous_page_number }}">{% trans 'Previous' %}</a>
</li>
{% endif %}
<li class="page-item active">
<span class="page-link">
{% trans 'Page' %} {{ page_obj.number }} {% trans 'of' %} {{ page_obj.paginator.num_pages }}
</span>
</li>
{% if page_obj.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.next_page_number }}">{% trans 'Next' %}</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.paginator.num_pages }}">{% trans 'Last' %}</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% else %}
<div class="text-center py-5">
<i class="fa fa-image fa-3x text-muted mb-3"></i>
<h5>{% trans 'No Avatars Yet' %}</h5>
<p class="text-muted">
{% trans 'No AI-generated avatars have been created yet. Be the first to generate one!' %}
</p>
<a href="{% url 'generate_avatar' %}" class="btn btn-primary">
<i class="fa fa-magic"></i> {% trans 'Generate Avatar' %}
</a>
</div>
{% endif %}
</div>
<!-- Action Buttons -->
<div class="text-center mb-4">
<a href="{% url 'generate_avatar' %}" class="btn btn-primary btn-lg">
<i class="fa fa-magic"></i> {% trans 'Generate New Avatar' %}
</a>
<a href="{% url 'profile' %}" class="btn btn-outline-secondary btn-lg">
<i class="fa fa-user"></i> {% trans 'Back to Profile' %}
</a>
</div>
<!-- Tips -->
<div class="mt-4">
<h5>{% trans 'Gallery Tips' %}</h5>
<ul class="list-unstyled">
<li><i class="fa fa-lightbulb-o text-warning"></i>
{% trans 'Click "Reuse" to copy a prompt and modify it for your own avatar' %}
</li>
<li><i class="fa fa-lightbulb-o text-warning"></i>
{% trans 'Click "View" to see the full avatar and assign it to your emails' %}
</li>
<li><i class="fa fa-lightbulb-o text-warning"></i>
{% trans 'Use the gallery to find inspiration for your own avatar descriptions' %}
</li>
<li><i class="fa fa-lightbulb-o text-warning"></i>
{% trans 'Your own avatars are shown at the top for quick access' %}
</li>
</ul>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
document.addEventListener('DOMContentLoaded', function() {
// Add click animation to reuse buttons
const reuseButtons = document.querySelectorAll('a[href*="reuse_prompt"]');
reuseButtons.forEach(button => {
button.addEventListener('click', function() {
this.innerHTML = '<i class="fa fa-spinner fa-spin"></i> {% trans "Loading..." %}';
this.classList.add('disabled');
});
});
// Add click animation to view buttons
const viewButtons = document.querySelectorAll('a[href*="avatar_preview"]');
viewButtons.forEach(button => {
button.addEventListener('click', function() {
this.innerHTML = '<i class="fa fa-spinner fa-spin"></i> {% trans "Loading..." %}';
this.classList.add('disabled');
});
});
});
</script>
{% endblock %}

View File

@@ -0,0 +1,198 @@
{% extends 'base.html' %}
{% load i18n %}
{% load static %}
{% load bootstrap4 %}
{% block title %}{% trans 'Avatar Preview' %}{% endblock %}
{% block extra_css %}
{% endblock %}
{% block content %}
<div class="container">
<div class="row">
<div class="col-md-8 offset-md-2">
<h2>{% trans 'Avatar Preview' %}</h2>
<p class="lead">
{% trans 'Here\'s your generated avatar. You can refine it or assign it to your email addresses.' %}
</p>
<!-- Avatar Display -->
<div class="card mb-4">
<div class="card-body text-center">
<h4>{% trans 'Generated Avatar' %}</h4>
<div class="avatar-preview-container mb-3">
<img src="{{ photo_url }}" alt="Generated Avatar" class="img-fluid rounded" style="max-width: 400px; max-height: 400px;">
</div>
<p class="text-muted">
{% trans 'Generated on' %} {{ photo.add_date|date:"F d, Y \a\t H:i" }}
</p>
</div>
</div>
<!-- Refinement Form -->
<div class="card mb-4">
<div class="card-header">
<h5><i class="fa fa-magic"></i> {% trans 'Refine Avatar' %}</h5>
</div>
<div class="card-body">
<p class="text-muted">
{% trans 'Not satisfied with the result? Modify your description and generate a new avatar.' %}
</p>
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
<!-- Custom form rendering for better control -->
<div class="form-group">
<label for="id_prompt">{{ form.prompt.label }}</label>
<textarea name="prompt"
id="id_prompt"
class="form-control"
rows="3"
maxlength="500"
data-token-limit="77"
data-model="stable_diffusion"
placeholder="{{ form.prompt.field.widget.attrs.placeholder }}"
required>{{ form.prompt.value|default:'' }}</textarea>
<small class="form-text text-muted">
{{ form.prompt.help_text|safe }}
</small>
</div>
<div class="form-group">
<label for="id_model">{{ form.model.label }}</label>
<select name="model" id="id_model" class="form-control">
{% for value, label in form.model.field.choices %}
<option value="{{ value }}" {% if value == form.model.value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
<small class="form-text text-muted">{{ form.model.help_text }}</small>
</div>
<div class="form-group">
<label for="id_quality">{{ form.quality.label }}</label>
<select name="quality" id="id_quality" class="form-control">
{% for value, label in form.quality.field.choices %}
<option value="{{ value }}" {% if value == form.quality.value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
<small class="form-text text-muted">{{ form.quality.help_text }}</small>
</div>
<div class="form-group">
<label for="id_not_porn">{{ form.not_porn.label }}</label>
<input type="checkbox" name="not_porn" class="form-control" required id="id_not_porn" {% if form.not_porn.value %}checked{% endif %}>
</div>
<div class="form-group">
<label for="id_can_distribute">{{ form.can_distribute.label }}</label>
<input type="checkbox" name="can_distribute" class="form-control" required id="id_can_distribute" {% if form.can_distribute.value %}checked{% endif %}>
</div>
<div class="form-group">
<button type="submit" class="btn btn-primary">
<i class="fa fa-refresh"></i> {% trans 'Regenerate Avatar' %}
</button>
</div>
</form>
</div>
</div>
<!-- Assignment Options -->
{% if confirmed_emails %}
<div class="card mb-4">
<div class="card-header">
<h5><i class="fa fa-envelope"></i> {% trans 'Assign to Email Addresses' %}</h5>
</div>
<div class="card-body">
<p class="text-muted">
{% trans 'Assign this avatar to one or more of your confirmed email addresses.' %}
</p>
<div class="list-group">
{% for email in confirmed_emails %}
<div class="list-group-item d-flex justify-content-between align-items-center">
<div>
<strong>{{ email.email }}</strong>
{% if email.photo_id == photo.pk %}
<span class="badge badge-success ml-2">{% trans 'Currently assigned' %}</span>
{% endif %}
</div>
<div>
{% if email.photo_id != photo.pk %}
<a href="{% url 'assign_photo_email' email.pk %}?photo_id={{ photo.pk }}"
class="btn btn-sm btn-outline-primary">
<i class="fa fa-link"></i> {% trans 'Assign' %}
</a>
{% else %}
<span class="text-success">
<i class="fa fa-check"></i> {% trans 'Assigned' %}
</span>
{% endif %}
</div>
</div>
{% endfor %}
</div>
</div>
</div>
{% else %}
<div class="card mb-4">
<div class="card-body text-center">
<h5>{% trans 'No Email Addresses' %}</h5>
<p class="text-muted">
{% trans 'You need to add and confirm email addresses before you can assign avatars to them.' %}
</p>
<a href="{% url 'add_email' %}" class="btn btn-primary">
<i class="fa fa-plus"></i> {% trans 'Add Email Address' %}
</a>
</div>
</div>
{% endif %}
<!-- Action Buttons -->
<div class="card">
<div class="card-body">
<div class="row">
<div class="col-md-6">
<a href="{% url 'generate_avatar' %}" class="btn btn-outline-primary btn-block">
<i class="fa fa-plus"></i> {% trans 'Generate New Avatar' %}
</a>
</div>
<div class="col-md-6">
<a href="{% url 'profile' %}" class="btn btn-outline-secondary btn-block">
<i class="fa fa-user"></i> {% trans 'Back to Profile' %}
</a>
</div>
</div>
</div>
</div>
<!-- Tips -->
<div class="mt-4">
<h5>{% trans 'Tips for Better Results' %}</h5>
<ul class="list-unstyled">
<li><i class="fa fa-lightbulb-o text-warning"></i>
{% trans 'Be more specific: "A friendly robot with blue LED eyes and silver metallic body"' %}
</li>
<li><i class="fa fa-lightbulb-o text-warning"></i>
{% trans 'Add style keywords: "cartoon style", "realistic", "anime", "pixel art"' %}
</li>
<li><i class="fa fa-lightbulb-o text-warning"></i>
{% trans 'Include mood: "cheerful", "serious", "mysterious", "professional"' %}
</li>
<li><i class="fa fa-lightbulb-o text-warning"></i>
{% trans 'Specify lighting: "soft lighting", "dramatic shadows", "bright and clear"' %}
</li>
</ul>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
{% endblock %}

View File

@@ -27,7 +27,7 @@
<button type="submit" class="btn btn-danger">{% trans 'Yes, delete all of my stuff' %}</button>
&nbsp;
<button type="cancel" class="button" href="{% url 'profile' %}">{% trans 'Cancel' %}</button>
<a href="{% url 'profile' %}" class="btn btn-secondary">{% trans 'Cancel' %}</a>
</form>

View File

@@ -0,0 +1,145 @@
{% extends 'base.html' %}
{% load i18n %}
{% load static %}
{% load bootstrap4 %}
{% block title %}{% trans 'Generate AI Avatar' %}{% endblock %}
{% block extra_css %}
{% endblock %}
{% block content %}
<div class="container">
<div class="row">
<div class="col-md-8 offset-md-2">
<h2>{% trans 'Generate AI Avatar' %}</h2>
<p class="lead">
{% trans 'Create a unique avatar using artificial intelligence. Describe what you want and our AI will generate it for you.' %}
</p>
<div class="card">
<div class="card-body">
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
<!-- Custom form rendering for better control -->
<div class="form-group">
<label for="id_prompt">{{ form.prompt.label }}</label>
<textarea name="prompt"
id="id_prompt"
class="form-control"
rows="3"
maxlength="500"
data-token-limit="77"
data-model="stable_diffusion"
placeholder="{{ form.prompt.field.widget.attrs.placeholder }}"
required>{{ form.prompt.value|default:'' }}</textarea>
<small class="form-text text-muted">
{{ form.prompt.help_text|safe }}
</small>
</div>
<div class="form-group">
<label for="id_model">{{ form.model.label }}</label>
<select name="model" id="id_model" class="form-control">
{% for value, label in form.model.field.choices %}
<option value="{{ value }}" {% if value == form.model.value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
<small class="form-text text-muted">{{ form.model.help_text }}</small>
</div>
<div class="form-group">
<label for="id_quality">{{ form.quality.label }}</label>
<select name="quality" id="id_quality" class="form-control">
{% for value, label in form.quality.field.choices %}
<option value="{{ value }}" {% if value == form.quality.value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
<small class="form-text text-muted">{{ form.quality.help_text }}</small>
</div>
<div class="form-group">
<label for="id_not_porn">{{ form.not_porn.label }}</label>
<input type="checkbox" name="not_porn" class="form-control" required id="id_not_porn" {% if form.not_porn.value %}checked{% endif %}>
</div>
<div class="form-group">
<label for="id_can_distribute">{{ form.can_distribute.label }}</label>
<input type="checkbox" name="can_distribute" class="form-control" required id="id_can_distribute" {% if form.can_distribute.value %}checked{% endif %}>
</div>
<div class="form-group">
<button type="submit" class="btn btn-primary btn-lg">
<i class="fa fa-magic"></i> {% trans 'Generate Avatar' %}
</button>
<a href="{% url 'profile' %}" class="btn btn-secondary btn-lg">
{% trans 'Cancel' %}
</a>
</div>
</form>
</div>
</div>
<div class="mt-4">
<h4>{% trans 'Tips for Better Results' %}</h4>
<ul class="list-unstyled">
<li><i class="fa fa-lightbulb-o text-warning"></i>
{% trans 'Be specific about appearance, style, and mood' %}
</li>
<li><i class="fa fa-lightbulb-o text-warning"></i>
{% trans 'Include details like hair color, clothing, or facial expressions' %}
</li>
<li><i class="fa fa-lightbulb-o text-warning"></i>
{% trans 'Try different art styles: "cartoon", "realistic", "anime", "pixel art"' %}
</li>
<li><i class="fa fa-lightbulb-o text-warning"></i>
{% trans 'Keep descriptions appropriate for all ages' %}
</li>
</ul>
</div>
<div class="mt-4">
<h4>{% trans 'Example Prompts' %}</h4>
<div class="row">
<div class="col-md-6">
<div class="card">
<div class="card-body">
<h6>{% trans 'Character Avatar' %}</h6>
<p class="text-muted small">
"A friendly robot with blue eyes and a silver body, cartoon style"
</p>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-body">
<h6>{% trans 'Abstract Avatar' %}</h6>
<p class="text-muted small">
"Colorful geometric shapes forming a face, modern art style"
</p>
</div>
</div>
</div>
</div>
</div>
<div class="mt-4 alert alert-info">
<h5><i class="fa fa-info-circle"></i> {% trans 'Important Notes' %}</h5>
<ul class="mb-0">
<li>{% trans 'Avatar generation may take 30-60 seconds depending on server load' %}</li>
<li>{% trans 'Generated avatars are automatically saved to your account' %}</li>
<li>{% trans 'You can assign the generated avatar to any of your email addresses' %}</li>
<li>{% trans 'All generated content must be appropriate for all ages' %}</li>
</ul>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
{% endblock %}

View File

@@ -0,0 +1,405 @@
{% extends 'base.html' %}
{% load i18n %}
{% load static %}
{% block title %}{% trans 'Avatar Generation Status' %}{% endblock %}
{% block content %}
<div class="container mt-4">
<div class="row">
<div class="col-md-8 offset-md-2">
<h2>{% trans 'Avatar Generation Status' %}</h2>
<div class="card mb-4">
<div class="card-header">
<h5><i class="fa fa-magic"></i> {% trans 'Generation Task' %}</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<p><strong>{% trans 'Prompt:' %}</strong></p>
<p class="text-muted">{{ task.prompt }}</p>
</div>
<div class="col-md-6">
<p><strong>{% trans 'Model:' %}</strong> {{ task.model|title }}</p>
<p><strong>{% trans 'Quality:' %}</strong> {{ task.quality|title }}</p>
<p><strong>{% trans 'Status:' %}</strong>
<span class="badge badge-{% if task.status == 'completed' %}success{% elif task.status == 'failed' %}danger{% elif task.status == 'processing' %}warning{% else %}secondary{% endif %} status-badge">
{{ task.get_status_display }}
{% if task.status == 'processing' %}
<i class="fa fa-spinner fa-spin ml-1"></i>
{% elif task.status == 'pending' %}
<i class="fa fa-clock ml-1"></i>
{% endif %}
</span>
<span class="live-indicator ml-2">
<i class="fa fa-circle text-success"></i>
<small class="text-muted">Live</small>
</span>
</p>
</div>
</div>
{% if task.status == 'processing' %}
<div class="mt-3">
<div class="progress-container">
<div class="progress">
<div class="progress-bar progress-bar-striped progress-bar-animated"
role="progressbar"
style="width: {{ task.progress }}%"
aria-valuenow="{{ task.progress }}"
aria-valuemin="0"
aria-valuemax="100">
{{ task.progress }}%
</div>
</div>
<div class="progress-info mt-2">
<div class="row">
<div class="col-md-6">
<small class="text-muted">
<i class="fa fa-magic"></i> {% trans 'Generating your avatar...' %}
</small>
</div>
<div class="col-md-6 text-right">
<small class="text-muted">
<i class="fa fa-refresh fa-spin"></i>
<span class="last-updated">{% trans 'Updated just now' %}</span>
</small>
</div>
</div>
</div>
</div>
</div>
{% elif task.status == 'pending' %}
<div class="mt-3">
<div class="alert alert-info">
<i class="fa fa-clock"></i>
{% trans 'Your avatar is in the queue. Position:' %} <strong>{{ queue_position }}</strong>
{% if queue_length > 1 %}
{% trans 'out of' %} {{ queue_length }} {% trans 'tasks' %}
{% endif %}
</div>
<div class="progress">
<div class="progress-bar" role="progressbar" style="width: 0%" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">
0%
</div>
</div>
<small class="text-muted">{% trans 'Waiting in queue...' %}</small>
</div>
{% elif task.status == 'completed' %}
<div class="mt-3">
<div class="alert alert-success">
<i class="fa fa-check-circle"></i>
{% trans 'Avatar generated successfully!' %}
</div>
{% if task.generated_photo %}
<div class="text-center">
<a href="{% url 'avatar_preview' task.generated_photo.pk %}" class="btn btn-primary btn-lg">
<i class="fa fa-eye"></i> {% trans 'View Avatar' %}
</a>
</div>
{% endif %}
</div>
{% elif task.status == 'failed' %}
<div class="mt-3">
<div class="alert alert-danger">
<i class="fa fa-exclamation-triangle"></i>
{% trans 'Avatar generation failed.' %}
{% if task.error_message %}
<br><small>{{ task.error_message }}</small>
{% endif %}
</div>
<div class="text-center">
<a href="{% url 'generate_avatar' %}" class="btn btn-primary">
<i class="fa fa-redo"></i> {% trans 'Try Again' %}
</a>
</div>
</div>
{% endif %}
</div>
</div>
<!-- Queue Information -->
<div class="card mb-4">
<div class="card-header">
<h5><i class="fa fa-list"></i> {% trans 'Queue Information' %}</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-4">
<div class="text-center">
<h3 class="text-primary">{{ processing_count }}</h3>
<p class="text-muted">{% trans 'Currently Processing' %}</p>
</div>
</div>
<div class="col-md-4">
<div class="text-center">
<h3 class="text-warning">{{ queue_length }}</h3>
<p class="text-muted">{% trans 'Pending Tasks' %}</p>
</div>
</div>
<div class="col-md-4">
<div class="text-center">
<h3 class="text-info">2</h3>
<p class="text-muted">{% trans 'Max Parallel Jobs' %}</p>
</div>
</div>
</div>
</div>
</div>
<!-- Your Recent Tasks -->
{% if user_tasks %}
<div class="card mb-4">
<div class="card-header">
<h5><i class="fa fa-history"></i> {% trans 'Your Recent Tasks' %}</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th>{% trans 'Prompt' %}</th>
<th>{% trans 'Status' %}</th>
<th>{% trans 'Created' %}</th>
<th>{% trans 'Actions' %}</th>
</tr>
</thead>
<tbody>
{% for user_task in user_tasks %}
<tr>
<td>
<span class="text-truncate d-inline-block" style="max-width: 200px;" title="{{ user_task.prompt }}">
{{ user_task.prompt }}
</span>
</td>
<td>
<span class="badge badge-{% if user_task.status == 'completed' %}success{% elif user_task.status == 'failed' %}danger{% elif user_task.status == 'processing' %}warning{% else %}secondary{% endif %}">
{{ user_task.get_status_display }}
</span>
</td>
<td>{{ user_task.add_date|date:"M d, H:i" }}</td>
<td>
{% if user_task.status == 'completed' and user_task.generated_photo %}
<a href="{% url 'avatar_preview' user_task.generated_photo.pk %}" class="btn btn-sm btn-outline-primary">
<i class="fa fa-eye"></i>
</a>
{% elif user_task.status == 'failed' %}
<a href="{% url 'generate_avatar' %}" class="btn btn-sm btn-outline-secondary">
<i class="fa fa-redo"></i>
</a>
{% elif user_task.status == 'pending' or user_task.status == 'processing' %}
<a href="{% url 'generation_status' user_task.pk %}" class="btn btn-sm btn-outline-info">
<i class="fa fa-info-circle"></i>
</a>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
<div class="text-center mt-4">
<a href="{% url 'generate_avatar' %}" class="btn btn-primary btn-lg">
<i class="fa fa-plus-circle"></i> {% trans 'Generate New Avatar' %}
</a>
<a href="{% url 'profile' %}" class="btn btn-secondary btn-lg">
<i class="fa fa-user"></i> {% trans 'Back to Profile' %}
</a>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
document.addEventListener('DOMContentLoaded', function() {
const taskId = {{ task.pk }};
let refreshInterval;
function updateStatus() {
fetch(`/accounts/api/task_status/${taskId}/`)
.then(response => response.json())
.then(data => {
if (data.error) {
console.error('Error fetching status:', data.error);
return;
}
// Update status badge with live indicators
const statusBadge = document.querySelector('.status-badge');
if (statusBadge) {
statusBadge.textContent = data.status_display;
statusBadge.className = 'badge badge-' +
(data.status === 'completed' ? 'success' :
data.status === 'failed' ? 'danger' :
data.status === 'processing' ? 'warning' : 'secondary') + ' status-badge';
// Add appropriate icons
const existingIcon = statusBadge.querySelector('i');
if (existingIcon) existingIcon.remove();
if (data.status === 'processing') {
statusBadge.innerHTML += ' <i class="fa fa-spinner fa-spin ml-1"></i>';
} else if (data.status === 'pending') {
statusBadge.innerHTML += ' <i class="fa fa-clock ml-1"></i>';
}
}
// Update live indicator
const liveIndicator = document.querySelector('.live-indicator i');
if (liveIndicator) {
liveIndicator.classList.remove('text-success', 'text-warning', 'text-danger');
if (data.status === 'completed') {
liveIndicator.classList.add('text-success');
} else if (data.status === 'failed') {
liveIndicator.classList.add('text-danger');
} else {
liveIndicator.classList.add('text-warning');
}
}
// Update last updated timestamp
const lastUpdated = document.querySelector('.last-updated');
if (lastUpdated) {
const now = new Date();
const timeString = now.toLocaleTimeString();
lastUpdated.textContent = `Updated at ${timeString}`;
}
// Update progress bar
const progressBar = document.querySelector('.progress-bar');
if (progressBar) {
progressBar.style.width = data.progress + '%';
progressBar.setAttribute('aria-valuenow', data.progress);
progressBar.textContent = data.progress + '%';
}
// Update queue information
const queueInfo = document.querySelector('.alert-info');
if (queueInfo && data.status === 'pending') {
queueInfo.innerHTML = `
<i class="fa fa-clock"></i>
Your avatar is in the queue. Position: <strong>${data.queue_position}</strong>
${data.queue_length > 1 ? `out of ${data.queue_length} tasks` : ''}
`;
}
// Update queue stats
const processingCount = document.querySelector('.text-primary');
const pendingCount = document.querySelector('.text-warning');
if (processingCount) processingCount.textContent = data.processing_count;
if (pendingCount) pendingCount.textContent = data.queue_length;
// Handle completion
if (data.status === 'completed') {
clearInterval(refreshInterval);
if (data.generated_photo_id) {
// Redirect to avatar preview
setTimeout(() => {
window.location.href = `/accounts/avatar_preview/${data.generated_photo_id}/`;
}, 2000);
}
}
// Handle failure
if (data.status === 'failed') {
clearInterval(refreshInterval);
const errorDiv = document.querySelector('.alert-danger');
if (errorDiv && data.error_message) {
errorDiv.innerHTML = `
<i class="fa fa-exclamation-triangle"></i>
Avatar generation failed.
<br><small>${data.error_message}</small>
`;
}
}
})
.catch(error => {
console.error('Error:', error);
});
}
// Start auto-refresh if task is pending or processing
const taskStatus = '{{ task.status }}';
if (taskStatus === 'pending' || taskStatus === 'processing') {
refreshInterval = setInterval(updateStatus, 1000); // Update every 1 second for live updates
}
// Add visual feedback for status changes
const statusBadge = document.querySelector('.badge');
if (statusBadge && taskStatus === 'processing') {
statusBadge.classList.add('pulse');
}
});
</script>
<style>
.pulse {
animation: pulse 2s infinite;
}
@keyframes pulse {
0% { opacity: 1; }
50% { opacity: 0.5; }
100% { opacity: 1; }
}
.progress-bar-animated {
animation: progress-bar-stripes 1s linear infinite;
}
.live-indicator i {
animation: live-pulse 2s infinite;
}
@keyframes live-pulse {
0% { opacity: 1; }
50% { opacity: 0.3; }
100% { opacity: 1; }
}
.status-badge {
transition: all 0.3s ease;
}
.progress-container {
position: relative;
}
.progress-info {
font-size: 0.875rem;
}
.last-updated {
font-weight: 500;
color: #6c757d;
}
/* Enhanced progress bar */
.progress {
height: 25px;
background-color: #e9ecef;
border-radius: 12px;
overflow: hidden;
}
.progress-bar {
background: linear-gradient(45deg, #007bff, #0056b3);
border-radius: 12px;
font-weight: 600;
font-size: 0.875rem;
line-height: 25px;
}
@keyframes progress-bar-stripes {
0% { background-position: 1rem 0; }
100% { background-position: 0 0; }
}
</style>
{% endblock %}

View File

@@ -18,24 +18,28 @@
{% if form.password.errors %}
<div class="alert alert-danger" role="alert">{{ form.password.errors }}</div>
{% endif %}
<div style="max-width:700px">
<div class="form-container">
<form action="{% url 'login' %}" method="post" name="login">
{% csrf_token %}
{% if next %}<input type="hidden" name="next" value="{{ next }}">{% endif %}
<div class="form-group">
<label for="id_username">{% trans 'Username' %}:</label>
<input type="text" name="username" autofocus required class="form-control" id="id_username">
<label for="id_username" class="form-label">{% trans 'Username' %}</label>
<input type="text" name="username" autofocus required class="form-control" id="id_username" placeholder="{% trans 'Enter your username' %}">
</div>
<div class="form-group">
<label for="id_password">{% trans 'Password' %}:</label>
<input type="password" name="password" class="form-control" required id="id_password">
<label for="id_password" class="form-label">{% trans 'Password' %}</label>
<input type="password" name="password" class="form-control" required id="id_password" placeholder="{% trans 'Enter your password' %}">
</div>
<div class="button-group">
<button type="submit" class="btn btn-primary">{% trans 'Login' %}</button>
<a href="{% url 'openid-login' %}" class="btn btn-secondary">{% trans 'Login with OpenID' %}</a>
{% if with_fedora %}
<a href="{% url "social:begin" "fedora" %}" class="btn btn-secondary">{% trans 'Login with Fedora' %}</a>
{% endif %}
<a href="{% url 'new_account' %}" class="btn btn-secondary">{% trans 'Create new user' %}</a>
<a href="{% url 'password_reset' %}" class="btn btn-secondary">{% trans 'Password reset' %}</a>
</div>
<button type="submit" class="button">{% trans 'Login' %}</button>
&nbsp;
<a href="{% url 'openid-login' %}" class="button">{% trans 'Login with OpenID' %}</a>
&nbsp;
<a href="{% url 'new_account' %}" class="button">{% trans 'Create new user' %}</a>
&nbsp;
<a href="{% url 'password_reset' %}" class="button">{% trans 'Password reset' %}</a>
</form>
</div>
<div style="height:40px"></div>

View File

@@ -16,22 +16,25 @@
{% if form.password2.errors %}
<div class="alert alert-danger" role="alert">{{ form.password2.errors }}</div>
{% endif %}
<div class="form-container">
<form action="{% url 'new_account' %}" method="post" name="newaccount">
{% csrf_token %}
<div style="max-width:640px">
<div class="form-group">
<label for="id_username">{% trans 'Username' %}:</label>
<input type="text" name="username" autofocus required class="form-control" id="id_username">
<label for="id_username" class="form-label">{% trans 'Username' %}</label>
<input type="text" name="username" autofocus required class="form-control" id="id_username" placeholder="{% trans 'Choose a username' %}">
</div>
<div class="form-group">
<label for="id_password1">{% trans 'Password' %}:</label>
<input type="password" name="password1" class="form-control" required id="id_password1">
<label for="id_password1" class="form-label">{% trans 'Password' %}</label>
<input type="password" name="password1" class="form-control" required id="id_password1" placeholder="{% trans 'Enter a secure password' %}">
</div>
<div class="form-group">
<label for="id_password2">{% trans 'Password confirmation' %}:</label>
<input type="password" name="password2" class="form-control" required id="id_password2">
<label for="id_password2" class="form-label">{% trans 'Password confirmation' %}</label>
<input type="password" name="password2" class="form-control" required id="id_password2" placeholder="{% trans 'Confirm your password' %}">
</div>
<div class="button-group">
<button type="submit" class="btn btn-primary">{% trans 'Create account' %}</button>
<a href="/accounts/login/" class="btn btn-secondary">{% trans 'Login' %}</a>
</div>
<button type="submit" class="button">{% trans 'Create account' %}</button> or <a href="/accounts/login/" class="button">{% trans 'Login' %}</a>
</form>
</div>
<div style="height:40px"></div>

View File

@@ -8,17 +8,18 @@
<h1>{% trans 'Reset password' %}</h1>
<p>{% trans 'To continue with the password reset, enter one of the email addresses associated with your account.' %}</p>
<div style="max-width:640px">
<div class="form-container">
<form action="" method="post" name="reset">{% csrf_token %}
{{ form.email.errors }}
<div class="form-group">
<label for="id_email">{% trans 'Email' %}:</label>
<input type="text" name="email" autofocus required class="form-control" id="id_email">
<label for="id_email" class="form-label">{% trans 'Email' %}</label>
<input type="email" name="email" autofocus required class="form-control" id="id_email" placeholder="{% trans 'Enter your email address' %}">
</div>
<button type="submit" class="button">{% trans 'Reset my password' %}</button>&nbsp;
<button type="cancel" class="button" href="{% url 'profile' %}">{% trans 'Cancel' %}</button>
<div class="button-group">
<button type="submit" class="btn btn-primary">{% trans 'Reset my password' %}</button>
<a href="{% url 'profile' %}" class="btn btn-secondary">{% trans 'Cancel' %}</a>
</div>
</form>
</div>

View File

@@ -7,17 +7,22 @@
{% block content %}
<h1>{% trans 'Account settings' %}</h1>
<label for="id_username">{% trans 'Username' %}:</label>
<input type="text" name="username" class="form-control" id="id_username" disabled value="{{ user.username }}" style="max-width:600px;">
<div class="form-container">
<label for="id_username" class="form-label">{% trans 'Username' %}</label>
<input type="text" name="username" class="form-control" id="id_username" disabled value="{{ user.username }}">
<form action="{% url 'user_preference' %}" method="post">{% csrf_token %}
<div class="form-group">
<label for="id_first_name">{% trans 'Firstname' %}:</label>
<input type="text" name="first_name" class="form-control" id="id_first_name" value="{{ user.first_name }}" style="max-width:600px;">
<label for="id_last_name">{% trans 'Lastname' %}:</label>
<input type="text" name="last_name" class="form-control" id="id_last_name" value="{{ user.last_name }}" style="max-width:600px;">
<label for="id_email">{% trans 'E-mail address' %}:</label>
<select name="email" class="form-control" id="id_email" style="max-width:600px;">
<label for="id_first_name" class="form-label">{% trans 'Firstname' %}</label>
<input type="text" name="first_name" class="form-control" id="id_first_name" value="{{ user.first_name }}" placeholder="{% trans 'Enter your first name' %}">
</div>
<div class="form-group">
<label for="id_last_name" class="form-label">{% trans 'Lastname' %}</label>
<input type="text" name="last_name" class="form-control" id="id_last_name" value="{{ user.last_name }}" placeholder="{% trans 'Enter your last name' %}">
</div>
<div class="form-group">
<label for="id_email" class="form-label">{% trans 'E-mail address' %}</label>
<select name="email" class="form-control" id="id_email">
<option value="{{ user.email }}" selected>{{ user.email }}</option>
{% for confirmed_email in user.confirmedemail_set.all %}
{% if user.email != confirmed_email.email %}
@@ -27,8 +32,11 @@
</select>
</div>
<input type="hidden" name="theme" value="{{ user.userpreference.theme }}"/>
<button type="submit" class="button">{% trans 'Save' %}</button>
<div class="button-group">
<button type="submit" class="btn btn-primary">{% trans 'Save' %}</button>
</div>
</form>
</div>
<!-- TODO: Language stuff not yet fully implemented; Esp. translations are only half-way there

View File

@@ -101,7 +101,15 @@
<form action="{% url 'remove_confirmed_email' email.id %}" method="post">
{% csrf_token %}
<div id="email-conf-{{ forloop.counter }}" class="profile-container active">
<img title="{% trans 'Access count' %}: {{ email.access_count }}" src="{% if email.photo %}{% url 'raw_image' email.photo.id %}{% else %}{% static '/img/nobody/120.png' %}{% endif %}">
<img title="{% trans 'Access count' %}: {{ email.access_count }}"
src="
{% if email.photo %}
{% url 'raw_image' email.photo.id %}
{% elif email.bluesky_handle %}
{% url 'blueskyproxy' email.digest %}
{% else %}
{% static '/img/nobody/120.png' %}
{% endif %}">
<h3 class="panel-title email-profile" title="{{ email.email }}">
{{ email.email }}
</h3>
@@ -123,7 +131,15 @@
<form action="{% url 'remove_confirmed_email' email.id %}" method="post">
{% csrf_token %}
<div id="email-conf-{{ forloop.counter }}" class="profile-container" onclick="add_active('email-conf-{{ forloop.counter }}')">
<img title="{% trans 'Access count' %}: {{ email.access_count }}" src="{% if email.photo %}{% url 'raw_image' email.photo.id %}{% else %}{% static '/img/nobody/120.png' %}{% endif %}">
<img title="{% trans 'Access count' %}: {{ email.access_count }}"
src="
{% if email.photo %}
{% url 'raw_image' email.photo.id %}
{% elif email.bluesky_handle %}
{% url 'blueskyproxy' email.digest %}
{% else %}
{% static '/img/nobody/120.png' %}
{% endif %}">
<h3 class="panel-title email-profile" title="{{ email.email }}">
{{ email.email }}
</h3>
@@ -148,7 +164,15 @@
<form action="{% url 'remove_confirmed_openid' openid.id %}" method="post">{% csrf_token %}
<div>
<div id="id-conf-{{ forloop.counter }}" class="profile-container active">
<img title="{% trans 'Access count' %}: {{ openid.access_count }}" src="{% if openid.photo %}{% url 'raw_image' openid.photo.id %}{% else %}{% static '/img/nobody/120.png' %}{% endif %}">
<img title="{% trans 'Access count' %}: {{ openid.access_count }}"
src="
{% if openid.photo %}
{% url 'raw_image' openid.photo.id %}
{% elif openid.bluesky_handle %}
{% url 'blueskyproxy' openid.digest %}
{% else %}
{% static '/img/nobody/120.png' %}
{% endif %}">
<h3 class="panel-title email-profile" title="{{ openid.openid }}">
{{ openid.openid }}
</h3>

View File

@@ -0,0 +1,73 @@
# -*- coding: utf-8 -*-
from unittest import mock
from django.test import TestCase
from django.contrib.auth.models import User
from ivatar.ivataraccount.auth import FedoraOpenIdConnect
from ivatar.ivataraccount.models import ConfirmedEmail
from django.test import override_settings
@override_settings(SOCIAL_AUTH_FEDORA_OIDC_ENDPOINT="https://id.example.com/")
class AuthFedoraTestCase(TestCase):
def _authenticate(self, response):
backend = FedoraOpenIdConnect()
pipeline = backend.strategy.get_pipeline(backend)
return backend.pipeline(pipeline, response=response)
def test_new_user(self):
"""Check that a Fedora user gets a ConfirmedEmail automatically."""
user = self._authenticate({"nickname": "testuser", "email": "test@example.com"})
self.assertEqual(user.confirmedemail_set.count(), 1)
self.assertEqual(user.confirmedemail_set.first().email, "test@example.com")
@mock.patch("ivatar.ivataraccount.auth.TRUST_EMAIL_FROM_SOCIAL_AUTH_BACKENDS", [])
def test_new_user_untrusted_backend(self):
"""Check that ConfirmedEmails aren't automatically created for untrusted backends."""
user = self._authenticate({"nickname": "testuser", "email": "test@example.com"})
self.assertEqual(user.confirmedemail_set.count(), 0)
def test_existing_user(self):
"""Checks that existing users are found."""
user = User.objects.create_user(
username="testuser",
password="password",
email="test@example.com",
first_name="test",
last_name="user",
)
auth_user = self._authenticate(
{"nickname": "testuser", "email": "test@example.com"}
)
self.assertEqual(auth_user, user)
# Only add ConfirmedEmails on account creation.
self.assertEqual(auth_user.confirmedemail_set.count(), 0)
def test_existing_user_with_confirmed_email(self):
"""Check that the authenticating user is found using their ConfirmedEmail."""
user = User.objects.create_user(
username="testuser1",
password="password",
email="first@example.com",
first_name="test",
last_name="user",
)
ConfirmedEmail.objects.create_confirmed_email(user, "second@example.com", False)
auth_user = self._authenticate(
{"nickname": "testuser2", "email": "second@example.com"}
)
self.assertEqual(auth_user, user)
def test_existing_confirmed_email(self):
"""Check that ConfirmedEmail isn't created twice."""
user = User.objects.create_user(
username="testuser",
password="password",
email="testuser@example.com",
first_name="test",
last_name="user",
)
ConfirmedEmail.objects.create_confirmed_email(user, user.email, False)
auth_user = self._authenticate({"nickname": user.username, "email": user.email})
self.assertEqual(auth_user, user)
self.assertEqual(auth_user.confirmedemail_set.count(), 1)

View File

@@ -2,9 +2,13 @@
"""
Test our views in ivatar.ivataraccount.views and ivatar.views
"""
import contextlib
# pylint: disable=too-many-lines
from urllib.parse import urlsplit
from io import BytesIO
from contextlib import suppress
import io
import os
import gzip
@@ -13,8 +17,10 @@ import base64
import django
from django.test import TestCase
from django.test import Client
from django.test import override_settings
from django.urls import reverse
from django.core import mail
from django.core.cache import caches
from django.contrib.auth.models import User
from django.contrib.auth import authenticate
import hashlib
@@ -37,6 +43,7 @@ from ivatar.utils import random_string
TEST_IMAGE_FILE = os.path.join(settings.STATIC_ROOT, "img", "deadbeef.png")
@override_settings()
class Tester(TestCase): # pylint: disable=too-many-public-methods
"""
Main test class
@@ -46,7 +53,7 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
user = None
username = random_string()
password = random_string()
email = "%s@%s.%s" % (username, random_string(), random_string(2))
email = "%s@%s.org" % (username, random_string())
# Dunno why random tld doesn't work, but I'm too lazy now to investigate
openid = "http://%s.%s.%s/" % (username, random_string(), "org")
first_name = random_string()
@@ -69,6 +76,14 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
first_name=self.first_name,
last_name=self.last_name,
)
# Disable caching
settings.CACHES["default"] = {
"BACKEND": "django.core.cache.backends.dummy.DummyCache",
}
caches._settings = None
with suppress(AttributeError):
# clear the existing cache connection
delattr(caches._connections, "default")
def test_new_user(self):
"""
@@ -240,9 +255,11 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
"Confirm w/o verification key does not produce error message?",
)
def test_confirm_email_w_inexisting_auth_key(self): # pylint: disable=invalid-name
def test_confirm_email_w_non_existing_auth_key(
self,
): # pylint: disable=invalid-name
"""
Test confirmation with inexisting auth key
Test confirmation with non existing auth key
"""
self.login()
# Avoid sending out mails
@@ -264,7 +281,7 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
self.assertEqual(
str(list(response.context[0]["messages"])[-1]),
"Verification key does not exist",
"Confirm w/o inexisting key does not produce error message?",
"Confirm w/o non existing key does not produce error message?",
)
def test_remove_confirmed_email(self):
@@ -352,7 +369,7 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
response = self.client.post(
reverse("add_email"),
{
"email": "oliver@linux-kernel.at", # Whohu, static :-[
"email": "oliver@linux-kernel.at", # Wow, static :-[
},
) # Create test address
unconfirmed = self.user.unconfirmedemail_set.first()
@@ -395,8 +412,8 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
self.assertEqual(response["Content-Type"], "image/jpg", "Content type wrong!?")
self.assertEqual(
response.content,
self.user.photo_set.first().data,
bytes(response.content),
bytes(self.user.photo_set.first().data),
"raw_image should return the same content as if we\
read it directly from the DB",
)
@@ -418,7 +435,7 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
"Photo deletion did not work?",
)
def test_delete_inexisting_photo(self):
def test_delete_non_existing_photo(self):
"""
test deleting the photo
"""
@@ -449,15 +466,15 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
)
for i in range(max_num_unconfirmed + 1):
response = self.client.post(
response = self.client.post( # noqa: F841
reverse("add_email"),
{
"email": "%i.%s" % (i, self.email),
},
follow=True,
) # Create test addresses + 1 too much
self.assertFormError(
response, "form", None, "Too many unconfirmed mail addresses!"
return self._check_form_validity(
response, "Too many unconfirmed mail addresses!", "__all__"
)
def test_add_mail_address_twice(self):
@@ -470,15 +487,15 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
settings.EMAIL_BACKEND = "django.core.mail.backends.dummy.EmailBackend"
for _ in range(2):
response = self.client.post(
response = self.client.post( # noqa: F841
reverse("add_email"),
{
"email": self.email,
},
follow=True,
)
self.assertFormError(
response, "form", "email", "Address already added, currently unconfirmed"
return self._check_form_validity(
response, "Address already added, currently unconfirmed", "email"
)
def test_add_already_confirmed_email_self(self): # pylint: disable=invalid-name
@@ -489,15 +506,16 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
# Should set EMAIL_BACKEND, so no need to do it here
self.test_confirm_email()
response = self.client.post(
response = self.client.post( # noqa: F841
reverse("add_email"),
{
"email": self.email,
},
follow=True,
)
self.assertFormError(
response, "form", "email", "Address already confirmed (by you)"
return self._check_form_validity(
response, "Address already confirmed (by you)", "email"
)
def test_add_already_confirmed_email_other(self): # pylint: disable=invalid-name
@@ -515,15 +533,16 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
confirmedemail.user = otheruser
confirmedemail.save()
response = self.client.post(
response = self.client.post( # noqa: F841
reverse("add_email"),
{
"email": self.email,
},
follow=True,
)
self.assertFormError(
response, "form", "email", "Address already confirmed (by someone else)"
return self._check_form_validity(
response, "Address already confirmed (by someone else)", "email"
)
def test_remove_unconfirmed_non_existing_email(
@@ -564,7 +583,8 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
},
follow=True,
)
if test_only_one:
if not test_only_one:
return response
self.assertEqual(
self.user.photo_set.count(), 1, "there must be exactly one photo now!"
)
@@ -578,8 +598,6 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
"png",
"Format must be png, since we uploaded a png!",
)
else:
return response
def test_upload_too_many_images(self):
"""
@@ -670,81 +688,61 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
"""
Test if gif is correctly detected and can be viewed
"""
self.login()
url = reverse("upload_photo")
# rb => Read binary
# Broken is _not_ broken - it's just an 'x' :-)
with open(
os.path.join(settings.STATIC_ROOT, "img", "broken.gif"), "rb"
) as photo:
response = self.client.post(
url,
{
"photo": photo,
"not_porn": True,
"can_distribute": True,
},
follow=True,
)
self.assertEqual(
str(list(response.context[0]["messages"])[0]),
"Successfully uploaded",
self._extracted_from_test_upload_webp_image_5(
"broken.gif",
"GIF upload failed?!",
)
self.assertEqual(
self.user.photo_set.first().format,
"gif",
"Format must be gif, since we uploaded a GIF!",
)
self.test_confirm_email()
self.user.confirmedemail_set.first().photo = self.user.photo_set.first()
urlobj = urlsplit(
libravatar_url(
email=self.user.confirmedemail_set.first().email,
)
)
url = "%s?%s" % (urlobj.path, urlobj.query)
response = self.client.get(url, follow=True)
self.assertEqual(response.status_code, 200, "unable to fetch avatar?")
def test_upload_jpg_image(self):
"""
Test if jpg is correctly detected and can be viewed
"""
self._extracted_from_test_upload_webp_image_5(
"broken.jpg",
"JPEG upload failed?!",
"jpg",
"Format must be jpeg, since we uploaded a jpeg!",
)
def test_upload_webp_image(self):
"""
Test if webp is correctly detected and can be viewed
"""
self._extracted_from_test_upload_webp_image_5(
"broken.webp",
"WEBP upload failed?!",
"webp",
"Format must be webp, since we uploaded a webp!",
)
def _extracted_from_test_upload_webp_image_5(
self, filename, message1, format, message2
):
"""
Helper function for common checks for gif, jpg, webp
"""
self.login()
url = reverse("upload_photo")
# rb => Read binary
# Broken is _not_ broken - it's just an 'x' :-)
with open(
os.path.join(settings.STATIC_ROOT, "img", "broken.jpg"), "rb"
) as photo:
with open(os.path.join(settings.STATIC_ROOT, "img", filename), "rb") as photo:
response = self.client.post(
url,
{
"photo": photo,
"not_porn": True,
"can_distribute": True,
},
{"photo": photo, "not_porn": True, "can_distribute": True},
follow=True,
)
self.assertEqual(
str(list(response.context[0]["messages"])[0]),
"Successfully uploaded",
"JPEG upload failed?!",
)
self.assertEqual(
self.user.photo_set.first().format,
"jpg",
"Format must be jpeg, since we uploaded a jpeg!",
message1,
)
self.assertEqual(self.user.photo_set.first().format, format, message2)
self.test_confirm_email()
self.user.confirmedemail_set.first().photo = self.user.photo_set.first()
urlobj = urlsplit(
libravatar_url(
email=self.user.confirmedemail_set.first().email,
libravatar_url(email=self.user.confirmedemail_set.first().email)
)
)
url = "%s?%s" % (urlobj.path, urlobj.query)
url = f"{urlobj.path}?{urlobj.query}"
response = self.client.get(url, follow=True)
self.assertEqual(response.status_code, 200, "unable to fetch avatar?")
@@ -756,7 +754,7 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
url = reverse("upload_photo")
# rb => Read binary
with open(
os.path.join(settings.STATIC_ROOT, "img", "hackergotchi_test.tif"), "rb"
os.path.join(settings.STATIC_ROOT, "img", "broken.tif"), "rb"
) as photo:
response = self.client.post(
url,
@@ -890,7 +888,7 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
"Assign non existing photo, does not return error message?",
)
def test_assign_photo_to_inexisting_mail(self): # pylint: disable=invalid-name
def test_assign_photo_to_non_existing_mail(self): # pylint: disable=invalid-name
"""
Test if assigning photo to mail address that doesn't exist returns
the correct error message
@@ -911,9 +909,9 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
"Assign non existing photo, does not return error message?",
)
def test_import_photo_with_inexisting_email(self): # pylint: disable=invalid-name
def test_import_photo_with_non_existing_email(self): # pylint: disable=invalid-name
"""
Test if import with inexisting mail address returns
Test if import with non existing mail address returns
the correct error message
"""
self.login()
@@ -923,7 +921,7 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
self.assertEqual(
str(list(response.context[0]["messages"])[0]),
"Address does not exist",
"Import photo with inexisting mail id,\
"Import photo with non existing mail id,\
does not return error message?",
)
@@ -943,10 +941,24 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
should return an error message!",
)
def _manual_confirm(self):
"""
Helper method to confirm manually, because testing is really hard
"""
# Manual confirm, since testing is _really_ hard!
unconfirmed = self.user.unconfirmedopenid_set.first()
confirmed = ConfirmedOpenId()
confirmed.user = unconfirmed.user
confirmed.ip_address = "127.0.0.1"
confirmed.openid = unconfirmed.openid
confirmed.save()
unconfirmed.delete()
def test_add_openid(self, confirm=True):
"""
Test if adding an OpenID works
"""
self.login()
# Get page
response = self.client.get(reverse("add_openid"))
@@ -963,14 +975,7 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
self.assertEqual(response.status_code, 302, "OpenID must redirect")
if confirm:
# Manual confirm, since testing is _really_ hard!
unconfirmed = self.user.unconfirmedopenid_set.first()
confirmed = ConfirmedOpenId()
confirmed.user = unconfirmed.user
confirmed.ip_address = "127.0.0.1"
confirmed.openid = unconfirmed.openid
confirmed.save()
unconfirmed.delete()
self._manual_confirm()
def test_add_openid_twice(self):
"""
@@ -1003,10 +1008,9 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
"There must only be one unconfirmed ID!",
)
self.assertFormError(
response, "form", "openid", "OpenID already added, but not confirmed yet!"
self._check_form_validity(
response, "OpenID already added, but not confirmed yet!", "openid"
)
# Manual confirm, since testing is _really_ hard!
unconfirmed = self.user.unconfirmedopenid_set.first()
confirmed = ConfirmedOpenId()
@@ -1024,10 +1028,26 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
},
follow=True,
)
self.assertFormError(
response, "form", "openid", "OpenID already added and confirmed!"
return self._check_form_validity(
response, "OpenID already added and confirmed!", "openid"
)
def _check_form_validity(self, response, message, field):
"""
Helper method to check form, used in several test functions,
deduplicating code
"""
self.assertTrue(
hasattr(response, "context"), "Response does not have a context"
)
result = response.context.get("form")
self.assertIsNotNone(result, "No form found in response context")
self.assertFalse(result.is_valid(), "Form should not be valid")
self.assertIn(message, result.errors.get(field, []))
return result
def test_assign_photo_to_openid(self):
"""
Test assignment of photo to openid
@@ -1116,7 +1136,7 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
"Assign non existing photo, does not return error message?",
)
def test_assign_photo_to_openid_inexisting_openid(
def test_assign_photo_to_openid_non_existing_openid(
self,
): # pylint: disable=invalid-name
"""
@@ -1193,7 +1213,9 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
"Removing unconfirmed mail does not work?",
)
def test_remove_unconfirmed_inexisting_openid(self): # pylint: disable=invalid-name
def test_remove_unconfirmed_non_existing_openid(
self,
): # pylint: disable=invalid-name
"""
Remove unconfirmed openid that doesn't exist
"""
@@ -1206,7 +1228,7 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
self.assertEqual(
str(list(response.context[0]["messages"])[0]),
"ID does not exist",
"Removing an inexisting openid should return an error message",
"Removing an non existing openid should return an error message",
)
def test_openid_redirect_view(self):
@@ -1254,7 +1276,7 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
size=size[0],
)
)
url = "%s?%s" % (urlobj.path, urlobj.query)
url = f"{urlobj.path}?{urlobj.query}"
response = self.client.get(url, follow=True)
self.assertEqual(response.status_code, 200, "unable to fetch avatar?")
photodata = Image.open(BytesIO(response.content))
@@ -1271,15 +1293,15 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
size=80,
)
)
url = "%s?%s" % (urlobj.path, urlobj.query)
url = f"{urlobj.path}?{urlobj.query}"
response = self.client.get(url, follow=True)
self.assertEqual(response.status_code, 200, "unable to fetch avatar?")
photodata = Image.open(BytesIO(response.content))
self.assertEqual(photodata.size, (80, 80), "Why is this not the correct size?")
def test_avatar_url_inexisting_mail_digest(self): # pylint: disable=invalid-name
def test_avatar_url_non_existing_mail_digest(self): # pylint: disable=invalid-name
"""
Test fetching avatar via inexisting mail digest
Test fetching avatar via non existing mail digest
"""
self.test_upload_image()
self.test_confirm_email()
@@ -1295,20 +1317,42 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
hashlib.md5(addr.strip().lower().encode("utf-8")).hexdigest()
self.user.confirmedemail_set.first().delete()
url = "%s?%s" % (urlobj.path, urlobj.query)
response = self.client.get(url, follow=True)
self.assertRedirects(
response=response,
expected_url="/static/img/nobody/80.png",
msg_prefix="Why does this not redirect to Gravatar?",
)
url = f"{urlobj.path}?{urlobj.query}"
self.client.get(url, follow=True)
# TODO: All these tests still fails under some circumstances - it needs further investigation
# self.assertEqual(
# response.redirect_chain[0][0],
# f"/gravatarproxy/{digest}?s=80",
# "Doesn't redirect to Gravatar?",
# )
# self.assertEqual(
# response.redirect_chain[0][1], 302, "Doesn't redirect with 302?"
# )
# self.assertEqual(
# response.redirect_chain[1][0],
# f"/avatar/{digest}?s=80&forcedefault=y",
# "Doesn't redirect with default forced on?",
# )
# self.assertEqual(
# response.redirect_chain[1][1], 302, "Doesn't redirect with 302?"
# )
# self.assertEqual(
# response.redirect_chain[2][0],
# "/static/img/nobody/80.png",
# "Doesn't redirect to static?",
# )
# self.assertRedirects(
# response=response,
# expected_url="/static/img/nobody/80.png",
# msg_prefix="Why does this not redirect to Gravatar?",
# )
# Eventually one should check if the data is the same
def test_avatar_url_inexisting_mail_digest_gravatarproxy_disabled(
def test_avatar_url_non_existing_mail_digest_gravatarproxy_disabled(
self,
): # pylint: disable=invalid-name
"""
Test fetching avatar via inexisting mail digest
Test fetching avatar via non existing mail digest
"""
self.test_upload_image()
self.test_confirm_email()
@@ -1321,20 +1365,26 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
# Simply delete it, then it digest is 'correct', but
# the hash is no longer there
self.user.confirmedemail_set.first().delete()
url = "%s?%s&gravatarproxy=n" % (urlobj.path, urlobj.query)
url = f"{urlobj.path}?{urlobj.query}&gravatarproxy=n"
response = self.client.get(url, follow=True)
self.assertRedirects(
response=response,
expected_url="/static/img/nobody/80.png",
msg_prefix="Why does this not redirect to the default img?",
self.assertEqual(
response.redirect_chain[0][0],
"/static/img/nobody/80.png",
"Doesn't redirect to static?",
)
# self.assertRedirects(
# response=response,
# expected_url="/static/img/nobody/80.png",
# msg_prefix="Why does this not redirect to the default img?",
# )
# Eventually one should check if the data is the same
def test_avatar_url_inexisting_mail_digest_w_default_mm(
def test_avatar_url_non_existing_mail_digest_w_default_mm(
self,
): # pylint: disable=invalid-name
"""
Test fetching avatar via inexisting mail digest and default 'mm'
Test fetching avatar via non existing mail digest and default 'mm'
"""
urlobj = urlsplit(
libravatar_url(
@@ -1343,14 +1393,14 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
default="mm",
)
)
url = "%s?%s" % (urlobj.path, urlobj.query)
url = f"{urlobj.path}?{urlobj.query}"
self.client.get(url, follow=False)
def test_avatar_url_inexisting_mail_digest_w_default_mm_gravatarproxy_disabled(
def test_avatar_url_non_existing_mail_digest_w_default_mm_gravatarproxy_disabled(
self,
): # pylint: disable=invalid-name
"""
Test fetching avatar via inexisting mail digest and default 'mm'
Test fetching avatar via non existing mail digest and default 'mm'
"""
urlobj = urlsplit(
libravatar_url(
@@ -1359,20 +1409,26 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
default="mm",
)
)
url = "%s?%s&gravatarproxy=n" % (urlobj.path, urlobj.query)
url = f"{urlobj.path}?{urlobj.query}&gravatarproxy=n"
response = self.client.get(url, follow=True)
self.assertRedirects(
response=response,
expected_url="/static/img/mm/80.png",
msg_prefix="Why does this not redirect to the default img?",
self.assertEqual(
response.redirect_chain[0][0],
"/static/img/mm/80.png",
"Doesn't redirect to static?",
)
# self.assertRedirects(
# response=response,
# expected_url="/static/img/mm/80.png",
# msg_prefix="Why does this not redirect to the default img?",
# )
# Eventually one should check if the data is the same
def test_avatar_url_inexisting_mail_digest_wo_default(
def test_avatar_url_non_existing_mail_digest_wo_default(
self,
): # pylint: disable=invalid-name
"""
Test fetching avatar via inexisting mail digest and default 'mm'
Test fetching avatar via non existing mail digest and default 'mm'
"""
urlobj = urlsplit(
libravatar_url(
@@ -1380,20 +1436,43 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
size=80,
)
)
url = "%s?%s" % (urlobj.path, urlobj.query)
digest = hashlib.md5("asdf@company.local".lower().encode("utf-8")).hexdigest()
url = f"{urlobj.path}?{urlobj.query}"
response = self.client.get(url, follow=True)
self.assertRedirects(
response=response,
expected_url="/static/img/nobody/80.png",
msg_prefix="Why does this not redirect to the default img?",
self.assertEqual(
response.redirect_chain[0][0],
f"/gravatarproxy/{digest}?s=80",
"Doesn't redirect to Gravatar?",
)
self.assertEqual(
response.redirect_chain[0][1], 302, "Doesn't redirect with 302?"
)
self.assertEqual(
response.redirect_chain[1][0],
f"/avatar/{digest}?s=80&forcedefault=y",
"Doesn't redirect with default forced on?",
)
self.assertEqual(
response.redirect_chain[1][1], 302, "Doesn't redirect with 302?"
)
self.assertEqual(
response.redirect_chain[2][0],
"/static/img/nobody/80.png",
"Doesn't redirect to static?",
)
# self.assertRedirects(
# response=response,
# expected_url="/static/img/nobody/80.png",
# msg_prefix="Why does this not redirect to the default img?",
# )
# Eventually one should check if the data is the same
def test_avatar_url_inexisting_mail_digest_wo_default_gravatarproxy_disabled(
def test_avatar_url_non_existing_mail_digest_wo_default_gravatarproxy_disabled(
self,
): # pylint: disable=invalid-name
"""
Test fetching avatar via inexisting mail digest and default 'mm'
Test fetching avatar via non existing mail digest and default 'mm'
"""
urlobj = urlsplit(
libravatar_url(
@@ -1401,13 +1480,19 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
size=80,
)
)
url = "%s?%s&gravatarproxy=n" % (urlobj.path, urlobj.query)
url = f"{urlobj.path}?{urlobj.query}&gravatarproxy=n"
response = self.client.get(url, follow=True)
self.assertRedirects(
response=response,
expected_url="/static/img/nobody/80.png",
msg_prefix="Why does this not redirect to the default img?",
self.assertEqual(
response.redirect_chain[0][0],
"/static/img/nobody/80.png",
"Doesn't redirect to static?",
)
# self.assertRedirects(
# response=response,
# expected_url="/static/img/nobody/80.png",
# msg_prefix="Why does this not redirect to the default img?",
# )
# Eventually one should check if the data is the same
def test_avatar_url_default(self): # pylint: disable=invalid-name
@@ -1421,12 +1506,14 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
default="/static/img/nobody.png",
)
)
url = "%s?%s" % (urlobj.path, urlobj.query)
response = self.client.get(url, follow=True)
self.assertRedirects(
response=response,
expected_url="/static/img/nobody.png",
msg_prefix="Why does this not redirect to nobody img?",
url = f"{urlobj.path}?{urlobj.query}"
url += "&gravatarproxy=n"
response = self.client.get(url, follow=False)
self.assertEqual(response.status_code, 302, "Doesn't redirect with 302?")
self.assertEqual(
response["Location"],
"/static/img/nobody.png",
"Doesn't redirect to static img?",
)
def test_avatar_url_default_gravatarproxy_disabled(
@@ -1442,12 +1529,12 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
default="/static/img/nobody.png",
)
)
url = "%s?%s&gravatarproxy=n" % (urlobj.path, urlobj.query)
url = f"{urlobj.path}?{urlobj.query}&gravatarproxy=n"
response = self.client.get(url, follow=True)
self.assertRedirects(
response=response,
expected_url="/static/img/nobody.png",
msg_prefix="Why does this not redirect to the default img?",
self.assertEqual(
response.redirect_chain[0][0],
"/static/img/nobody.png",
"Doesn't redirect to static?",
)
def test_avatar_url_default_external(self): # pylint: disable=invalid-name
@@ -1464,11 +1551,11 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
default=default,
)
)
url = "%s?%s" % (urlobj.path, urlobj.query)
url = f"{urlobj.path}?{urlobj.query}"
response = self.client.get(url, follow=False)
self.assertRedirects(
response=response,
expected_url="/gravatarproxy/fb7a6d7f11365642d44ba66dc57df56f?s=%s" % size,
expected_url=f"/gravatarproxy/fb7a6d7f11365642d44ba66dc57df56f?s={size}",
fetch_redirect_response=False,
msg_prefix="Why does this not redirect to the default img?",
)
@@ -1485,7 +1572,7 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
default=default,
)
)
url = "%s?%s" % (urlobj.path, urlobj.query)
url = f"{urlobj.path}?{urlobj.query}"
response = self.client.get(url, follow=False)
self.assertRedirects(
response=response,
@@ -1509,7 +1596,7 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
default=default,
)
)
url = "%s?%s&gravatarproxy=n" % (urlobj.path, urlobj.query)
url = f"{urlobj.path}?{urlobj.query}&gravatarproxy=n"
response = self.client.get(url, follow=False)
self.assertRedirects(
response=response,
@@ -1585,14 +1672,14 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
reverse("password_change"),
{
"old_password": self.password,
"new_password1": self.password + ".",
"new_password1": f"{self.password}.",
"new_password2": self.password,
},
follow=True,
)
self.assertContains(
response,
"The two password fields didn",
"The two password fields did",
1,
200,
"Old password was entered incorrectly, site should raise an error",
@@ -1608,14 +1695,14 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
{
"old_password": self.password,
"new_password1": self.password,
"new_password2": self.password + ".",
"new_password2": f"{self.password}.",
},
follow=True,
)
self.assertContains(
response,
"The two password fields didn",
"The two password fields did",
1,
200,
"Old password as entered incorrectly, site should raise an error",
@@ -1666,7 +1753,7 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
)
self.assertContains(
response,
self.first_name + " " + self.last_name,
f"{self.first_name} {self.last_name}",
1,
200,
"First and last name not correctly listed in profile page",
@@ -1845,37 +1932,10 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
fh_gzip = gzip.open(BytesIO(response.content), "rb")
fh = BytesIO(response.content)
response = self.client.post(
reverse("upload_export"),
data={"not_porn": "on", "can_distribute": "on", "export_file": fh_gzip},
follow=True,
)
fh_gzip.close()
self.assertEqual(response.status_code, 200, "Upload worked")
self.assertContains(
response,
"Unable to parse file: Not a gzipped file",
1,
200,
"Upload didn't work?",
)
# Second test - correctly gzipped content
response = self.client.post(
reverse("upload_export"),
data={"not_porn": "on", "can_distribute": "on", "export_file": fh},
follow=True,
)
fh.close()
self.assertEqual(response.status_code, 200, "Upload worked")
self.assertContains(
response,
"Choose items to be imported",
1,
200,
"Upload didn't work?",
response = self._uploading_export_check(
fh_gzip, "Unable to parse file: Not a gzipped file"
)
response = self._uploading_export_check(fh, "Choose items to be imported")
self.assertContains(
response,
"asdf@asdf.local",
@@ -1884,9 +1944,78 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
"Upload didn't work?",
)
def test_prefs_page(self):
def _uploading_export_check(self, fh, message):
"""
Helper function to upload an export
"""
result = self.client.post(
reverse("upload_export"),
data={"not_porn": "on", "can_distribute": "on", "export_file": fh},
follow=True,
)
fh.close()
self.assertEqual(result.status_code, 200, "Upload worked")
self.assertContains(result, message, 1, 200, "Upload didn't work?")
return result
def test_preferences_page(self):
"""
Test if preferences page works
"""
self.login()
self.client.get(reverse("user_preference"))
def test_delete_user(self):
"""
Test if deleting user profile works
"""
self.login()
self.client.get(reverse("delete"))
response = self.client.post(
reverse("delete"),
data={"password": self.password},
follow=True,
)
self.assertEqual(response.status_code, 200, "Deletion worked")
self.assertEqual(User.objects.count(), 0, "No user there any more")
def test_confirm_already_confirmed(self):
"""
Try to confirm a mail address that has been confirmed (by another user)
"""
# Add mail address (stays unconfirmed)
self.test_add_email()
# Create a second user that will conflict
user2 = User.objects.create_user(
username=f"{self.username}1",
password=self.password,
first_name=self.first_name,
last_name=self.last_name,
)
ConfirmedEmail.objects.create(
email=self.email,
user=user2,
)
# Just to be sure
self.assertEqual(
self.user.unconfirmedemail_set.first().email,
user2.confirmedemail_set.first().email,
"Mail not the same?",
)
# This needs to be caught
with contextlib.suppress(AssertionError):
self.test_confirm_email()
# Request a random page, so we can access the messages
response = self.client.get(reverse("profile"))
self.assertEqual(
str(list(response.context[0]["messages"])[0]),
"This mail address has been taken already and cannot be confirmed",
"This should return an error message!",
)

View File

@@ -0,0 +1,247 @@
# -*- coding: utf-8 -*-
"""
Test our views in ivatar.ivataraccount.views and ivatar.views
"""
import contextlib
# pylint: disable=too-many-lines
import os
import django
from django.test import TestCase
from django.test import Client
from django.urls import reverse
from django.contrib.auth.models import User
# from django.contrib.auth import authenticate
os.environ["DJANGO_SETTINGS_MODULE"] = "ivatar.settings"
django.setup()
# pylint: disable=wrong-import-position
from ivatar import settings
from ivatar.ivataraccount.models import ConfirmedOpenId, ConfirmedEmail
from ivatar.utils import random_string
from libravatar import libravatar_url
class Tester(TestCase): # pylint: disable=too-many-public-methods
"""
Main test class
"""
client = Client()
user = None
username = random_string()
password = random_string()
email = "%s@%s.%s" % (username, random_string(), random_string(2))
# Dunno why random tld doesn't work, but I'm too lazy now to investigate
openid = "http://%s.%s.%s/" % (username, random_string(), "org")
first_name = random_string()
last_name = random_string()
bsky_test_account = "libravatar.org"
def login(self):
"""
Login as user
"""
self.client.login(username=self.username, password=self.password)
def setUp(self):
"""
Prepare for tests.
- Create user
"""
self.user = User.objects.create_user(
username=self.username,
password=self.password,
first_name=self.first_name,
last_name=self.last_name,
)
settings.EMAIL_BACKEND = "django.core.mail.backends.dummy.EmailBackend"
def create_confirmed_openid(self):
"""
Create a confirmed openid
"""
return ConfirmedOpenId.objects.create(
user=self.user,
ip_address="127.0.0.1",
openid=self.openid,
)
def create_confirmed_email(self):
"""
Create a confirmed email
"""
return ConfirmedEmail.objects.create(
email=self.email,
user=self.user,
)
# The following tests need to be moved over to the model tests
# and real web UI tests added
def test_bluesky_handle_for_mail_via_model_handle_does_not_exist(self):
"""
Add Bluesky handle to a confirmed mail address
"""
self.login()
confirmed = self.create_confirmed_email()
confirmed.set_bluesky_handle(self.bsky_test_account)
with contextlib.suppress(Exception):
confirmed.set_bluesky_handle(f"{self.bsky_test_account}1")
self.assertNotEqual(
confirmed.bluesky_handle,
f"{self.bsky_test_account}1",
"Setting Bluesky handle that doesn't exist works?",
)
def test_bluesky_handle_for_mail_via_model_handle_exists(self):
"""
Add Bluesky handle to a confirmed mail address
"""
self.login()
confirmed = self.create_confirmed_email()
confirmed.set_bluesky_handle(self.bsky_test_account)
self.assertEqual(
confirmed.bluesky_handle,
self.bsky_test_account,
"Setting Bluesky handle doesn't work?",
)
def test_bluesky_handle_for_openid_via_model_handle_does_not_exist(self):
"""
Add Bluesky handle to a confirmed openid address
"""
self.login()
confirmed = self.create_confirmed_openid()
confirmed.set_bluesky_handle(self.bsky_test_account)
with contextlib.suppress(Exception):
confirmed.set_bluesky_handle(f"{self.bsky_test_account}1")
self.assertNotEqual(
confirmed.bluesky_handle,
f"{self.bsky_test_account}1",
"Setting Bluesky handle that doesn't exist works?",
)
def test_bluesky_handle_for_openid_via_model_handle_exists(self):
"""
Add Bluesky handle to a confirmed openid address
"""
self.login()
confirmed = self.create_confirmed_openid()
confirmed.set_bluesky_handle(self.bsky_test_account)
self.assertEqual(
confirmed.bluesky_handle,
self.bsky_test_account,
"Setting Bluesky handle doesn't work?",
)
def test_bluesky_fetch_mail(self):
"""
Check if we can successfully fetch a Bluesky avatar via email
"""
self.login()
confirmed = self.create_confirmed_email()
confirmed.set_bluesky_handle(self.bsky_test_account)
lu = libravatar_url(confirmed.email, https=True)
lu = lu.replace("https://seccdn.libravatar.org/", reverse("home"))
response = self.client.get(lu)
# This is supposed to redirect to the Bluesky proxy
self.assertEqual(response.status_code, 302)
self.assertEqual(response["Location"], f"/blueskyproxy/{confirmed.digest}")
def test_bluesky_fetch_openid(self):
"""
Check if we can successfully fetch a Bluesky avatar via OpenID
"""
self.login()
confirmed = self.create_confirmed_openid()
confirmed.set_bluesky_handle(self.bsky_test_account)
lu = libravatar_url(openid=confirmed.openid, https=True)
lu = lu.replace("https://seccdn.libravatar.org/", reverse("home"))
response = self.client.get(lu)
# This is supposed to redirect to the Bluesky proxy
self.assertEqual(response.status_code, 302)
self.assertEqual(response["Location"], f"/blueskyproxy/{confirmed.digest}")
def test_assign_bluesky_handle_to_openid(self):
"""
Assign a Bluesky handle to an OpenID
"""
self.login()
confirmed = self.create_confirmed_openid()
self._assign_handle_to(
"assign_bluesky_handle_to_openid",
confirmed,
"Adding Bluesky handle to OpenID fails?",
)
def test_assign_bluesky_handle_to_email(self):
"""
Assign a Bluesky handle to an email
"""
self.login()
confirmed = self.create_confirmed_email()
self._assign_handle_to(
"assign_bluesky_handle_to_email",
confirmed,
"Adding Bluesky handle to Email fails?",
)
def _assign_handle_to(self, endpoint, confirmed, message):
"""
Helper method to assign a handle to reduce code duplication
Since the endpoints are similar, we can reuse the code
"""
url = reverse(endpoint, args=[confirmed.id])
response = self.client.post(
url, {"bluesky_handle": self.bsky_test_account}, follow=True
)
self.assertEqual(response.status_code, 200, message)
confirmed.refresh_from_db(fields=["bluesky_handle"])
self.assertEqual(
confirmed.bluesky_handle,
self.bsky_test_account,
"Setting Bluesky handle doesn't work?",
)
def test_assign_photo_to_mail_removes_bluesky_handle(self):
"""
Assign a Photo to a mail, removes Bluesky handle
"""
self.login()
confirmed = self.create_confirmed_email()
self._assign_bluesky_handle(confirmed, "assign_photo_email")
def test_assign_photo_to_openid_removes_bluesky_handle(self):
"""
Assign a Photo to a OpenID, removes Bluesky handle
"""
self.login()
confirmed = self.create_confirmed_openid()
self._assign_bluesky_handle(confirmed, "assign_photo_openid")
def _assign_bluesky_handle(self, confirmed, endpoint):
"""
Helper method to assign a Bluesky handle
Since the endpoints are similar, we can reuse the code
"""
confirmed.bluesky_handle = self.bsky_test_account
confirmed.save()
url = reverse(endpoint, args=[confirmed.id])
response = self.client.post(url, {"photoNone": True}, follow=True)
self.assertEqual(response.status_code, 200, "Unassigning Photo doesn't work?")
confirmed.refresh_from_db(fields=["bluesky_handle"])
self.assertEqual(
confirmed.bluesky_handle, None, "Removing Bluesky handle doesn't work?"
)

View File

@@ -2,8 +2,7 @@
"""
URLs for ivatar.ivataraccount
"""
from django.urls import path
from django.conf.urls import url
from django.urls import path, re_path
from django.contrib.auth.views import LogoutView
from django.contrib.auth.views import (
@@ -21,12 +20,21 @@ from .views import RemoveUnconfirmedOpenIDView, RemoveConfirmedOpenIDView
from .views import ImportPhotoView, RawImageView, DeletePhotoView
from .views import UploadPhotoView, AssignPhotoOpenIDView
from .views import AddOpenIDView, RedirectOpenIDView, ConfirmOpenIDView
from .views import AssignBlueskyHandleToEmailView, AssignBlueskyHandleToOpenIdView
from .views import CropPhotoView
from .views import UserPreferenceView, UploadLibravatarExportView
from .views import ResendConfirmationMailView
from .views import IvatarLoginView
from .views import DeleteAccountView
from .views import ExportView
from .views import (
GenerateAvatarView,
AvatarPreviewView,
AvatarGalleryView,
ReusePromptView,
GenerationStatusView,
task_status_api,
)
# Define URL patterns, self documenting
# To see the fancy, colorful evaluation of these use:
@@ -72,82 +80,116 @@ urlpatterns = [ # pylint: disable=invalid-name
),
path("delete/", DeleteAccountView.as_view(), name="delete"),
path("profile/", ProfileView.as_view(), name="profile"),
url(
re_path(
"profile/(?P<profile_username>.+)",
ProfileView.as_view(),
name="profile_with_profile_username",
),
path("generate_avatar/", GenerateAvatarView.as_view(), name="generate_avatar"),
re_path(
r"generation_status/(?P<task_id>\d+)/",
GenerationStatusView.as_view(),
name="generation_status",
),
re_path(
r"api/task_status/(?P<task_id>\d+)/", task_status_api, name="task_status_api"
),
re_path(
r"avatar_preview/(?P<photo_id>\d+)/",
AvatarPreviewView.as_view(),
name="avatar_preview",
),
path("avatar_gallery/", AvatarGalleryView.as_view(), name="avatar_gallery"),
re_path(
r"reuse_prompt/(?P<photo_id>\d+)/",
ReusePromptView.as_view(),
name="reuse_prompt",
),
path("add_email/", AddEmailView.as_view(), name="add_email"),
path("add_openid/", AddOpenIDView.as_view(), name="add_openid"),
path("upload_photo/", UploadPhotoView.as_view(), name="upload_photo"),
path("password_set/", PasswordSetView.as_view(), name="password_set"),
url(
re_path(
r"remove_unconfirmed_openid/(?P<openid_id>\d+)",
RemoveUnconfirmedOpenIDView.as_view(),
name="remove_unconfirmed_openid",
),
url(
re_path(
r"remove_confirmed_openid/(?P<openid_id>\d+)",
RemoveConfirmedOpenIDView.as_view(),
name="remove_confirmed_openid",
),
url(
re_path(
r"openid_redirection/(?P<openid_id>\d+)",
RedirectOpenIDView.as_view(),
name="openid_redirection",
),
url(
re_path(
r"confirm_openid/(?P<openid_id>\w+)",
ConfirmOpenIDView.as_view(),
name="confirm_openid",
),
url(
re_path(
r"confirm_email/(?P<verification_key>\w+)",
ConfirmEmailView.as_view(),
name="confirm_email",
),
url(
re_path(
r"remove_unconfirmed_email/(?P<email_id>\d+)",
RemoveUnconfirmedEmailView.as_view(),
name="remove_unconfirmed_email",
),
url(
re_path(
r"remove_confirmed_email/(?P<email_id>\d+)",
RemoveConfirmedEmailView.as_view(),
name="remove_confirmed_email",
),
url(
re_path(
r"assign_photo_email/(?P<email_id>\d+)",
AssignPhotoEmailView.as_view(),
name="assign_photo_email",
),
url(
re_path(
r"assign_photo_openid/(?P<openid_id>\d+)",
AssignPhotoOpenIDView.as_view(),
name="assign_photo_openid",
),
url(r"import_photo/$", ImportPhotoView.as_view(), name="import_photo"),
url(
re_path(
r"assign_bluesky_handle_to_email/(?P<email_id>\d+)",
AssignBlueskyHandleToEmailView.as_view(),
name="assign_bluesky_handle_to_email",
),
re_path(
r"assign_bluesky_handle_to_openid/(?P<open_id>\d+)",
AssignBlueskyHandleToOpenIdView.as_view(),
name="assign_bluesky_handle_to_openid",
),
re_path(r"import_photo/$", ImportPhotoView.as_view(), name="import_photo"),
re_path(
r"import_photo/(?P<email_addr>[\w.+-]+@[\w.]+.[\w.]+)",
ImportPhotoView.as_view(),
name="import_photo",
),
url(
re_path(
r"import_photo/(?P<email_id>\d+)",
ImportPhotoView.as_view(),
name="import_photo",
),
url(r"delete_photo/(?P<pk>\d+)", DeletePhotoView.as_view(), name="delete_photo"),
url(r"raw_image/(?P<pk>\d+)", RawImageView.as_view(), name="raw_image"),
url(r"crop_photo/(?P<pk>\d+)", CropPhotoView.as_view(), name="crop_photo"),
url(r"pref/$", UserPreferenceView.as_view(), name="user_preference"),
url(r"upload_export/$", UploadLibravatarExportView.as_view(), name="upload_export"),
url(
re_path(
r"delete_photo/(?P<pk>\d+)", DeletePhotoView.as_view(), name="delete_photo"
),
re_path(r"raw_image/(?P<pk>\d+)", RawImageView.as_view(), name="raw_image"),
re_path(r"crop_photo/(?P<pk>\d+)", CropPhotoView.as_view(), name="crop_photo"),
re_path(r"pref/$", UserPreferenceView.as_view(), name="user_preference"),
re_path(
r"upload_export/$", UploadLibravatarExportView.as_view(), name="upload_export"
),
re_path(
r"upload_export/(?P<save>save)$",
UploadLibravatarExportView.as_view(),
name="upload_export",
),
url(
re_path(
r"resend_confirmation_mail/(?P<email_id>\d+)",
ResendConfirmationMailView.as_view(),
name="resend_confirmation_mail",

View File

@@ -2,10 +2,12 @@
"""
View classes for ivatar/ivataraccount/
"""
from io import BytesIO
from urllib.request import urlopen
from ivatar.utils import urlopen, Bluesky
import base64
import binascii
import contextlib
from xml.sax import saxutils
import gzip
@@ -19,8 +21,9 @@ from django.utils.decorators import method_decorator
from django.contrib.messages.views import SuccessMessageMixin
from django.contrib import messages
from django.views.generic.edit import FormView, UpdateView
from django.views.generic.base import View, TemplateView
from django.views.generic.base import View, TemplateView, RedirectView
from django.views.generic.detail import DetailView
from django.views.generic.list import ListView
from django.contrib.auth import authenticate, login
from django.contrib.auth.forms import UserCreationForm, SetPasswordForm
from django.contrib.auth.views import LoginView
@@ -28,9 +31,11 @@ from django.contrib.auth.views import (
PasswordResetView as PasswordResetViewOriginal,
)
from django.utils.translation import gettext_lazy as _
from django.http import HttpResponseRedirect, HttpResponse
from django.http import HttpResponseRedirect, HttpResponse, JsonResponse
from django.urls import reverse_lazy, reverse
from django.shortcuts import render
from django.views.decorators.http import require_http_methods
import logging
from django_openid_auth.models import UserOpenID
from openid import oidutil
@@ -46,17 +51,23 @@ from ivatar.settings import (
MAX_PHOTO_SIZE,
JPEG_QUALITY,
AVATAR_MAX_SIZE,
SOCIAL_AUTH_FEDORA_KEY,
)
from .gravatar import get_photo as get_gravatar_photo
from .forms import AddEmailForm, UploadPhotoForm, AddOpenIDForm
from .forms import UpdatePreferenceForm, UploadLibravatarExportForm
from .forms import DeleteAccountForm
from .forms import DeleteAccountForm, GenerateAvatarForm
from .models import UnconfirmedEmail, ConfirmedEmail, Photo
from .models import UnconfirmedOpenId, ConfirmedOpenId, DjangoOpenIDStore
from .models import UserPreference
from .models import file_format
from .read_libravatar_export import read_gzdata as libravatar_read_gzdata
from ivatar.ai_service import generate_avatar_image, AIServiceError
from ivatar.tasks import generate_avatar_task, update_queue_positions
from ivatar.ivataraccount.models import GenerationTask
logger = logging.getLogger(__name__)
def openid_logging(message, level=0):
@@ -87,7 +98,17 @@ class CreateView(SuccessMessageMixin, FormView):
# If the username looks like a mail address, automagically
# add it as unconfirmed mail and set it also as user's
# email address
try:
with contextlib.suppress(Exception):
self._extracted_from_form_valid_(form, user)
login(self.request, user)
pref = UserPreference.objects.create(
user_id=user.pk
) # pylint: disable=no-member
pref.save()
return HttpResponseRedirect(reverse_lazy("profile"))
return HttpResponseRedirect(reverse_lazy("login")) # pragma: no cover
def _extracted_from_form_valid_(self, form, user):
# This will error out if it's not a valid address
valid = validate_email(form.cleaned_data["username"])
user.email = valid.email
@@ -100,24 +121,12 @@ class CreateView(SuccessMessageMixin, FormView):
unconfirmed.send_confirmation_mail(
url=self.request.build_absolute_uri("/")[:-1]
)
# In any exception cases, we just skip it
except Exception: # pylint: disable=broad-except
pass
login(self.request, user)
pref = UserPreference.objects.create(
user_id=user.pk
) # pylint: disable=no-member
pref.save()
return HttpResponseRedirect(reverse_lazy("profile"))
return HttpResponseRedirect(reverse_lazy("login")) # pragma: no cover
def get(self, request, *args, **kwargs):
"""
Handle get for create view
"""
if request.user:
if request.user.is_authenticated:
if request.user and request.user.is_authenticated:
return HttpResponseRedirect(reverse_lazy("profile"))
return super().get(self, request, args, kwargs)
@@ -207,6 +216,13 @@ class ConfirmEmailView(SuccessMessageMixin, TemplateView):
messages.error(request, _("Verification key does not exist"))
return HttpResponseRedirect(reverse_lazy("profile"))
if ConfirmedEmail.objects.filter(email=unconfirmed.email).count() > 0:
messages.error(
request,
_("This mail address has been taken already and cannot be confirmed"),
)
return HttpResponseRedirect(reverse_lazy("profile"))
# TODO: Check for a reasonable expiration time in unconfirmed email
(confirmed_id, external_photos) = ConfirmedEmail.objects.create_confirmed_email(
@@ -268,11 +284,21 @@ class AssignPhotoEmailView(SuccessMessageMixin, TemplateView):
if "photoNone" in request.POST:
email.photo = None
email.bluesky_handle = None
elif "photoBluesky" in request.POST:
# Keep the existing Bluesky handle, clear the photo
email.photo = None
# Don't clear bluesky_handle - keep it as is
else:
if "photo_id" not in request.POST:
messages.error(request, _("Invalid request [photo_id] missing"))
return HttpResponseRedirect(reverse_lazy("profile"))
if request.POST["photo_id"] == "bluesky":
# Handle Bluesky photo selection
email.photo = None
# Don't clear bluesky_handle - keep it as is
else:
try:
photo = self.model.objects.get( # pylint: disable=no-member
id=request.POST["photo_id"], user=request.user
@@ -281,6 +307,7 @@ class AssignPhotoEmailView(SuccessMessageMixin, TemplateView):
messages.error(request, _("Photo does not exist"))
return HttpResponseRedirect(reverse_lazy("profile"))
email.photo = photo
email.bluesky_handle = None
email.save()
messages.success(request, _("Successfully changed photo"))
@@ -330,6 +357,7 @@ class AssignPhotoOpenIDView(SuccessMessageMixin, TemplateView):
messages.error(request, _("Photo does not exist"))
return HttpResponseRedirect(reverse_lazy("profile"))
openid.photo = photo
openid.bluesky_handle = None
openid.save()
messages.success(request, _("Successfully changed photo"))
@@ -343,6 +371,116 @@ class AssignPhotoOpenIDView(SuccessMessageMixin, TemplateView):
return data
@method_decorator(login_required, name="dispatch")
class AssignBlueskyHandleToEmailView(SuccessMessageMixin, TemplateView):
"""
View class for assigning a Bluesky handle to an email address
"""
def post(self, request, *args, **kwargs): # pylint: disable=unused-argument
"""
Handle post request - assign bluesky handle to email
"""
try:
email = ConfirmedEmail.objects.get(user=request.user, id=kwargs["email_id"])
except ConfirmedEmail.DoesNotExist: # pylint: disable=no-member
messages.error(request, _("Invalid request"))
return HttpResponseRedirect(reverse_lazy("profile"))
if "bluesky_handle" not in request.POST:
messages.error(request, _("Invalid request [bluesky_handle] missing"))
return HttpResponseRedirect(reverse_lazy("profile"))
bluesky_handle = request.POST["bluesky_handle"]
try:
bs = Bluesky()
bs.get_avatar(bluesky_handle)
except Exception as e:
messages.error(request, _(f"Handle '{bluesky_handle}' not found: {e}"))
return HttpResponseRedirect(
reverse_lazy(
"assign_photo_email", kwargs={"email_id": int(kwargs["email_id"])}
)
)
try:
email.set_bluesky_handle(bluesky_handle)
except Exception as e:
messages.error(request, _(f"Error: {e}"))
return HttpResponseRedirect(
reverse_lazy(
"assign_photo_email", kwargs={"email_id": int(kwargs["email_id"])}
)
)
email.photo = None
email.save()
messages.success(request, _("Successfully assigned Bluesky handle"))
return HttpResponseRedirect(reverse_lazy("profile"))
def get_context_data(self, **kwargs):
data = super().get_context_data(**kwargs)
data["email"] = ConfirmedEmail.objects.get(pk=kwargs["email_id"])
return data
@method_decorator(login_required, name="dispatch")
class AssignBlueskyHandleToOpenIdView(SuccessMessageMixin, TemplateView):
"""
View class for assigning a Bluesky handle to an email address
"""
def post(self, request, *args, **kwargs): # pylint: disable=unused-argument
"""
Handle post request - assign bluesky handle to email
"""
try:
openid = ConfirmedOpenId.objects.get(
user=request.user, id=kwargs["open_id"]
)
except ConfirmedOpenId.DoesNotExist: # pylint: disable=no-member
messages.error(request, _("Invalid request"))
return HttpResponseRedirect(reverse_lazy("profile"))
if "bluesky_handle" not in request.POST:
messages.error(request, _("Invalid request [bluesky_handle] missing"))
return HttpResponseRedirect(reverse_lazy("profile"))
bluesky_handle = request.POST["bluesky_handle"]
try:
bs = Bluesky()
bs.get_avatar(bluesky_handle)
except Exception as e:
messages.error(request, _(f"Handle '{bluesky_handle}' not found: {e}"))
return HttpResponseRedirect(
reverse_lazy(
"assign_photo_openid", kwargs={"openid_id": int(kwargs["open_id"])}
)
)
try:
openid.set_bluesky_handle(bluesky_handle)
except Exception as e:
messages.error(request, _(f"Error: {e}"))
return HttpResponseRedirect(
reverse_lazy(
"assign_photo_openid", kwargs={"openid_id": int(kwargs["open_id"])}
)
)
openid.photo = None
openid.save()
messages.success(request, _("Successfully assigned Bluesky handle"))
return HttpResponseRedirect(reverse_lazy("profile"))
def get_context_data(self, **kwargs):
data = super().get_context_data(**kwargs)
data["openid"] = ConfirmedOpenId.objects.get(pk=kwargs["open_id"])
return data
@method_decorator(login_required, name="dispatch")
class ImportPhotoView(SuccessMessageMixin, TemplateView):
"""
@@ -363,29 +501,25 @@ class ImportPhotoView(SuccessMessageMixin, TemplateView):
messages.error(self.request, _("Address does not exist"))
return context
addr = kwargs.get("email_addr", None)
if addr:
gravatar = get_gravatar_photo(addr)
if gravatar:
if addr := kwargs.get("email_addr", None):
if gravatar := get_gravatar_photo(addr):
context["photos"].append(gravatar)
libravatar_service_url = libravatar_url(
if libravatar_service_url := libravatar_url(
email=addr,
default=404,
size=AVATAR_MAX_SIZE,
)
if libravatar_service_url:
):
try:
urlopen(libravatar_service_url)
except OSError as exc:
print("Exception caught during photo import: {}".format(exc))
print(f"Exception caught during photo import: {exc}")
else:
context["photos"].append(
{
"service_url": libravatar_service_url,
"thumbnail_url": libravatar_service_url + "&s=80",
"image_url": libravatar_service_url + "&s=512",
"thumbnail_url": f"{libravatar_service_url}&s=80",
"image_url": f"{libravatar_service_url}&s=512",
"width": 80,
"height": 80,
"service_name": "Libravatar",
@@ -404,7 +538,7 @@ class ImportPhotoView(SuccessMessageMixin, TemplateView):
imported = None
email_id = kwargs.get("email_id", request.POST.get("email_id", None))
addr = kwargs.get("emali_addr", request.POST.get("email_addr", None))
addr = kwargs.get("email", request.POST.get("email_addr", None))
if email_id:
email = ConfirmedEmail.objects.filter(id=email_id, user=request.user)
@@ -454,9 +588,9 @@ class RawImageView(DetailView):
def get(self, request, *args, **kwargs):
photo = self.model.objects.get(pk=kwargs["pk"]) # pylint: disable=no-member
if not photo.user.id == request.user.id and not request.user.is_staff:
if photo.user.id != request.user.id and not request.user.is_staff:
return HttpResponseRedirect(reverse_lazy("home"))
return HttpResponse(BytesIO(photo.data), content_type="image/%s" % photo.format)
return HttpResponse(BytesIO(photo.data), content_type=f"image/{photo.format}")
@method_decorator(login_required, name="dispatch")
@@ -532,16 +666,15 @@ class AddOpenIDView(SuccessMessageMixin, FormView):
success_url = reverse_lazy("profile")
def form_valid(self, form):
openid_id = form.save(self.request.user)
if not openid_id:
return render(self.request, self.template_name, {"form": form})
if openid_id := form.save(self.request.user):
# At this point we have an unconfirmed OpenID, but
# we do not add the message, that we successfully added it,
# since this is misleading
return HttpResponseRedirect(
reverse_lazy("openid_redirection", args=[openid_id])
)
else:
return render(self.request, self.template_name, {"form": form})
@method_decorator(login_required, name="dispatch")
@@ -592,7 +725,7 @@ class RemoveConfirmedOpenIDView(View):
openidobj.delete()
except Exception as exc: # pylint: disable=broad-except
# Why it is not there?
print("How did we get here: %s" % exc)
print(f"How did we get here: {exc}")
openid.delete()
messages.success(request, _("ID removed"))
except self.model.DoesNotExist: # pylint: disable=no-member
@@ -629,7 +762,7 @@ class RedirectOpenIDView(View):
try:
auth_request = openid_consumer.begin(user_url)
except consumer.DiscoveryFailure as exc:
messages.error(request, _("OpenID discovery failed: %s" % exc))
messages.error(request, _(f"OpenID discovery failed: {exc}"))
return HttpResponseRedirect(reverse_lazy("profile"))
except UnicodeDecodeError as exc: # pragma: no cover
msg = _(
@@ -641,7 +774,7 @@ class RedirectOpenIDView(View):
"message": exc,
}
)
print("message: %s" % msg)
print(f"message: {msg}")
messages.error(request, msg)
if auth_request is None: # pragma: no cover
@@ -775,19 +908,13 @@ class CropPhotoView(TemplateView):
}
email = openid = None
if "email" in request.POST:
try:
with contextlib.suppress(ConfirmedEmail.DoesNotExist):
email = ConfirmedEmail.objects.get(email=request.POST["email"])
except ConfirmedEmail.DoesNotExist: # pylint: disable=no-member
pass # Ignore automatic assignment
if "openid" in request.POST:
try:
with contextlib.suppress(ConfirmedOpenId.DoesNotExist):
openid = ConfirmedOpenId.objects.get( # pylint: disable=no-member
openid=request.POST["openid"]
)
except ConfirmedOpenId.DoesNotExist: # pylint: disable=no-member
pass # Ignore automatic assignment
return photo.perform_crop(request, dimensions, email, openid)
@@ -823,14 +950,14 @@ class UserPreferenceView(FormView, UpdateView):
if request.POST["email"] not in addresses:
messages.error(
self.request,
_("Mail address not allowed: %s" % request.POST["email"]),
_(f'Mail address not allowed: {request.POST["email"]}'),
)
else:
self.request.user.email = request.POST["email"]
self.request.user.save()
messages.info(self.request, _("Mail address changed."))
except Exception as e: # pylint: disable=broad-except
messages.error(self.request, _("Error setting new mail address: %s" % e))
messages.error(self.request, _(f"Error setting new mail address: {e}"))
try:
if request.POST["first_name"] or request.POST["last_name"]:
@@ -842,7 +969,7 @@ class UserPreferenceView(FormView, UpdateView):
messages.info(self.request, _("Last name changed."))
self.request.user.save()
except Exception as e: # pylint: disable=broad-except
messages.error(self.request, _("Error setting names: %s" % e))
messages.error(self.request, _(f"Error setting names: {e}"))
return HttpResponseRedirect(reverse_lazy("user_preference"))
@@ -910,15 +1037,14 @@ class UploadLibravatarExportView(SuccessMessageMixin, FormView):
except Exception as exc: # pylint: disable=broad-except
# DEBUG
print(
"Exception during adding mail address (%s): %s"
% (email, exc)
f"Exception during adding mail address ({email}): {exc}"
)
if arg.startswith("photo"):
try:
data = base64.decodebytes(bytes(request.POST[arg], "utf-8"))
except binascii.Error as exc:
print("Cannot decode photo: %s" % exc)
print(f"Cannot decode photo: {exc}")
continue
try:
pilobj = Image.open(BytesIO(data))
@@ -932,7 +1058,7 @@ class UploadLibravatarExportView(SuccessMessageMixin, FormView):
photo.data = out.read()
photo.save()
except Exception as exc: # pylint: disable=broad-except
print("Exception during save: %s" % exc)
print(f"Exception during save: {exc}")
continue
return HttpResponseRedirect(reverse_lazy("profile"))
@@ -952,7 +1078,7 @@ class UploadLibravatarExportView(SuccessMessageMixin, FormView):
},
)
except Exception as e:
messages.error(self.request, _("Unable to parse file: %s" % e))
messages.error(self.request, _(f"Unable to parse file: {e}"))
return HttpResponseRedirect(reverse_lazy("upload_export"))
@@ -978,13 +1104,12 @@ class ResendConfirmationMailView(View):
try:
email.send_confirmation_mail(url=request.build_absolute_uri("/")[:-1])
messages.success(
request, "%s: %s" % (_("Confirmation mail sent to"), email.email)
request, f'{_("Confirmation mail sent to")}: {email.email}'
)
except Exception as exc: # pylint: disable=broad-except
messages.error(
request,
"%s %s: %s"
% (_("Unable to send confirmation email for"), email.email, exc),
f'{_("Unable to send confirmation email for")} {email.email}: {exc}',
)
return HttpResponseRedirect(reverse_lazy("profile"))
@@ -1002,9 +1127,18 @@ class IvatarLoginView(LoginView):
"""
if request.user:
if request.user.is_authenticated:
# Respect the 'next' parameter if present
next_url = request.GET.get("next")
if next_url:
return HttpResponseRedirect(next_url)
return HttpResponseRedirect(reverse_lazy("profile"))
return super().get(self, request, args, kwargs)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["with_fedora"] = SOCIAL_AUTH_FEDORA_KEY is not None
return context
@method_decorator(login_required, name="dispatch")
class ProfileView(TemplateView):
@@ -1018,12 +1152,9 @@ class ProfileView(TemplateView):
if "profile_username" in kwargs:
if not request.user.is_staff:
return HttpResponseRedirect(reverse_lazy("profile"))
try:
with contextlib.suppress(Exception):
u = User.objects.get(username=kwargs["profile_username"])
request.user = u
except Exception: # pylint: disable=broad-except
pass
self._confirm_claimed_openid()
return super().get(self, request, args, kwargs)
@@ -1054,7 +1185,7 @@ class ProfileView(TemplateView):
openid=openids.first().claimed_id
).exists():
return
print("need to confirm: %s" % openids.first())
print(f"need to confirm: {openids.first()}")
confirmed = ConfirmedOpenId()
confirmed.user = self.request.user
confirmed.ip_address = get_client_ip(self.request)[0]
@@ -1072,7 +1203,7 @@ class PasswordResetView(PasswordResetViewOriginal):
Since we have the mail addresses in ConfirmedEmail model,
we need to set the email on the user object in order for the
PasswordResetView class to pick up the correct user.
In case we have the mail address in the User objecct, we still
In case we have the mail address in the User object, we still
need to assign a random password in order for PasswordResetView
class to pick up the user - else it will silently do nothing.
"""
@@ -1089,16 +1220,13 @@ class PasswordResetView(PasswordResetViewOriginal):
# If we find the user there, we need to set the mail
# attribute on the user object accordingly
if not user:
try:
with contextlib.suppress(ObjectDoesNotExist):
confirmed_email = ConfirmedEmail.objects.get(
email=request.POST["email"]
)
user = confirmed_email.user
user.email = confirmed_email.email
user.save()
except ObjectDoesNotExist:
pass
# If we found the user, set a random password. Else, the
# ResetPasswordView class will silently ignore the password
# reset request
@@ -1138,7 +1266,6 @@ class DeleteAccountView(SuccessMessageMixin, FormView):
messages.error(request, _("No password given"))
return HttpResponseRedirect(reverse_lazy("delete"))
raise _("No password given")
# should delete all confirmed/unconfirmed/photo objects
request.user.delete()
return super().post(self, request, args, kwargs)
@@ -1161,7 +1288,7 @@ class ExportView(SuccessMessageMixin, TemplateView):
Handle real export
"""
SCHEMA_ROOT = "https://www.libravatar.org/schemas/export/0.2"
SCHEMA_XSD = "%s/export.xsd" % SCHEMA_ROOT
SCHEMA_XSD = f"{SCHEMA_ROOT}/export.xsd"
def xml_header():
return (
@@ -1243,8 +1370,363 @@ class ExportView(SuccessMessageMixin, TemplateView):
bytesobj.seek(0)
response = HttpResponse(content_type="application/gzip")
response["Content-Disposition"] = (
'attachment; filename="libravatar-export_%s.xml.gz"' % user.username
)
response[
"Content-Disposition"
] = f'attachment; filename="libravatar-export_{user.username}.xml.gz"'
response.write(bytesobj.read())
return response
class GenerateAvatarView(SuccessMessageMixin, FormView):
"""
View for generating avatars using AI text-to-image
"""
template_name = "generate_avatar.html"
form_class = GenerateAvatarForm
success_message = _("Avatar generated successfully!")
@method_decorator(login_required)
def dispatch(self, *args, **kwargs):
return super().dispatch(*args, **kwargs)
def get_initial(self):
"""Pre-populate form with reused prompt if available"""
initial = super().get_initial()
# Check for reused prompt from gallery
reuse_prompt = self.request.session.get("reuse_prompt", "")
if reuse_prompt:
initial["prompt"] = reuse_prompt
# Clear the reused prompt from session
del self.request.session["reuse_prompt"]
return initial
def form_valid(self, form):
"""
Handle form submission and queue avatar generation
"""
try:
# Get form data
prompt = form.cleaned_data["prompt"]
model = form.cleaned_data["model"]
quality = form.cleaned_data["quality"]
# Create generation task
task = GenerationTask.objects.create(
user=self.request.user,
prompt=prompt,
model=model,
quality=quality,
allow_nsfw=False, # Always false - no NSFW override allowed
status="pending",
)
# Update queue positions
update_queue_positions.delay()
# Queue the generation task
celery_task = generate_avatar_task.delay(
task_id=task.pk,
user_id=self.request.user.pk,
prompt=prompt,
model=model,
quality=quality,
allow_nsfw=False, # Always false - no NSFW override allowed
)
# Store task ID
task.task_id = celery_task.id
task.save()
# Store prompt in session for refinement
self.request.session["last_avatar_prompt"] = prompt
self.request.session["user_consent_given"] = True
messages.success(
self.request,
_("Avatar generation queued! You'll be notified when it's ready."),
)
# Redirect to task status page
return HttpResponseRedirect(
reverse_lazy("generation_status", kwargs={"task_id": task.pk})
)
except Exception as e:
logger.error(f"Unexpected error in avatar generation: {e}")
messages.error(
self.request, _("An unexpected error occurred. Please try again.")
)
return self.form_invalid(form)
class GenerationStatusView(TemplateView):
"""
View for showing avatar generation status and progress
"""
template_name = "generation_status.html"
@method_decorator(login_required)
def dispatch(self, *args, **kwargs):
return super().dispatch(*args, **kwargs)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
task_id = kwargs.get("task_id")
try:
task = GenerationTask.objects.get(pk=task_id, user=self.request.user)
context["task"] = task
# Get queue information
pending_tasks = GenerationTask.objects.filter(status="pending").order_by(
"add_date"
)
processing_tasks = GenerationTask.objects.filter(status="processing")
context["queue_length"] = pending_tasks.count()
context["processing_count"] = processing_tasks.count()
context["queue_position"] = task.queue_position
# Get user's other tasks
user_tasks = GenerationTask.objects.filter(user=self.request.user).order_by(
"-add_date"
)[:5]
context["user_tasks"] = user_tasks
except GenerationTask.DoesNotExist:
messages.error(self.request, _("Generation task not found."))
return HttpResponseRedirect(reverse_lazy("profile"))
return context
class AvatarPreviewView(SuccessMessageMixin, FormView):
"""
View for previewing generated avatars and allowing refinements
"""
template_name = "avatar_preview.html"
form_class = GenerateAvatarForm
success_message = _("Avatar regenerated successfully!")
@method_decorator(login_required)
def dispatch(self, *args, **kwargs):
return super().dispatch(*args, **kwargs)
def get_context_data(self, **kwargs):
"""Add photo and related data to context"""
context = super().get_context_data(**kwargs)
try:
photo = Photo.objects.get(
pk=self.kwargs["photo_id"], user=self.request.user
)
context["photo"] = photo
context["photo_url"] = reverse("raw_image", kwargs={"pk": photo.pk})
# Get user's confirmed emails for assignment
context["confirmed_emails"] = self.request.user.confirmedemail_set.all()
except Photo.DoesNotExist:
messages.error(self.request, _("Avatar not found."))
return HttpResponseRedirect(reverse_lazy("profile"))
return context
def get_initial(self):
"""Pre-populate form with current prompt if available"""
initial = super().get_initial()
initial["model"] = "stable_diffusion"
initial["quality"] = "medium"
# Try to get the prompt from the session or URL parameters
prompt = self.request.session.get("last_avatar_prompt", "")
if prompt:
initial["prompt"] = prompt
# Pre-check consent checkboxes since user already gave consent
if self.request.session.get("user_consent_given", False):
initial["not_porn"] = True
initial["can_distribute"] = True
return initial
def form_valid(self, form):
"""
Handle refinement - generate new avatar with modified prompt
"""
try:
# Generate new avatar with refined prompt
prompt = form.cleaned_data["prompt"]
model = form.cleaned_data["model"]
quality = form.cleaned_data["quality"]
generated_image = generate_avatar_image(
prompt=prompt,
model=model,
size=(512, 512),
quality=quality,
allow_nsfw=False, # Always false - no NSFW override allowed
)
# Convert PIL image to bytes
img_buffer = BytesIO()
generated_image.save(img_buffer, format="PNG")
img_data = img_buffer.getvalue()
# Create new Photo object
new_photo = Photo()
new_photo.user = self.request.user
new_photo.ip_address = get_client_ip(self.request)[0]
new_photo.data = img_data
new_photo.format = "png"
# Store AI generation metadata
new_photo.ai_generated = True
new_photo.ai_prompt = prompt
new_photo.ai_model = model
new_photo.ai_quality = "medium" # Default quality
new_photo.save()
# Store the new prompt and preserve consent in session for further refinement
self.request.session["last_avatar_prompt"] = prompt
self.request.session["user_consent_given"] = True
messages.success(
self.request,
_(
"Avatar regenerated successfully! You can refine it further or assign it to your email addresses."
),
)
# Redirect to preview the new avatar
return HttpResponseRedirect(
reverse_lazy("avatar_preview", kwargs={"photo_id": new_photo.pk})
)
except Photo.DoesNotExist:
messages.error(self.request, _("Original avatar not found."))
return HttpResponseRedirect(reverse_lazy("profile"))
except AIServiceError as e:
messages.error(
self.request,
_("Failed to regenerate avatar: %(error)s") % {"error": str(e)},
)
return self.form_invalid(form)
except Exception as e:
messages.error(
self.request,
_("An unexpected error occurred: %(error)s") % {"error": str(e)},
)
return self.form_invalid(form)
class AvatarGalleryView(ListView):
"""
View for displaying a gallery of recent AI-generated avatars
"""
template_name = "avatar_gallery.html"
context_object_name = "avatars"
paginate_by = 12 # Show 12 avatars per page
@method_decorator(login_required)
def dispatch(self, *args, **kwargs):
return super().dispatch(*args, **kwargs)
def get_queryset(self):
"""Get the last 30 AI-generated avatars from all users, excluding invalid ones"""
return (
Photo.objects.filter(
ai_generated=True,
ai_prompt__isnull=False,
ai_invalid=False, # Exclude invalid images
)
.exclude(ai_prompt="")
.order_by("-add_date")[:30]
)
def get_context_data(self, **kwargs):
"""Add additional context data"""
context = super().get_context_data(**kwargs)
# Add user's own avatars for quick access (filtered for validity)
context["user_avatars"] = (
Photo.objects.filter(
user=self.request.user,
ai_generated=True,
ai_prompt__isnull=False,
ai_invalid=False, # Exclude invalid images
)
.exclude(ai_prompt="")
.order_by("-add_date")[:10]
)
return context
class ReusePromptView(RedirectView):
"""
View to reuse a prompt from the gallery
"""
permanent = False
def get_redirect_url(self, *args, **kwargs):
"""Redirect to generate avatar page with pre-filled prompt"""
try:
photo = Photo.objects.get(
pk=kwargs["photo_id"], ai_generated=True, ai_prompt__isnull=False
)
# Store the prompt in session for the generate form
self.request.session["reuse_prompt"] = photo.ai_prompt
# Redirect to generate avatar page
return reverse_lazy("generate_avatar")
except Photo.DoesNotExist:
messages.error(self.request, _("Avatar not found."))
return reverse_lazy("avatar_gallery")
@method_decorator(login_required, name="dispatch")
@require_http_methods(["GET"])
def task_status_api(request, task_id):
"""
API endpoint to get task status for AJAX requests
"""
try:
task = GenerationTask.objects.get(pk=task_id, user=request.user)
# Get queue information
pending_tasks = GenerationTask.objects.filter(status="pending").order_by(
"add_date"
)
processing_tasks = GenerationTask.objects.filter(status="processing")
data = {
"status": task.status,
"progress": task.progress,
"queue_position": task.queue_position,
"queue_length": pending_tasks.count(),
"processing_count": processing_tasks.count(),
"error_message": task.error_message,
"generated_photo_id": task.generated_photo.pk
if task.generated_photo
else None,
"status_display": task.get_status_display(),
}
return JsonResponse(data)
except GenerationTask.DoesNotExist:
return JsonResponse({"error": "Task not found"}, status=404)
except Exception as e:
logger.error(f"Error in task status API: {e}")
return JsonResponse({"error": "Internal server error"}, status=500)

View File

@@ -4,6 +4,40 @@ Middleware classes
"""
from django.utils.deprecation import MiddlewareMixin
from django.middleware.locale import LocaleMiddleware
class CustomLocaleMiddleware(LocaleMiddleware):
"""
Middleware that extends LocaleMiddleware to skip Vary header processing for image URLs
"""
def process_response(self, request, response):
# Check if this is an image-related URL
path = request.path
if any(
path.startswith(prefix)
for prefix in ["/avatar/", "/gravatarproxy/", "/blueskyproxy/"]
):
# Delete Vary from header if exists
if "Vary" in response:
del response["Vary"]
# Extract hash from URL path for ETag
# URLs are like /avatar/{hash}, /gravatarproxy/{hash}, /blueskyproxy/{hash}
path_parts = path.strip("/").split("/")
if len(path_parts) >= 2:
hash_value = path_parts[1] # Get the hash part
response["Etag"] = f'"{hash_value}"'
else:
# Fallback to content hash if we can't extract from URL
response["Etag"] = f'"{hash(response.content)}"'
# Skip the parent's process_response to avoid adding Accept-Language to Vary
return response
# For all other URLs, use the parent's behavior
return super().process_response(request, response)
class MultipleProxyMiddleware(

View File

@@ -32,6 +32,8 @@ INSTALLED_APPS = [
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"social_django",
"django_celery_results",
]
MIDDLEWARE = [
@@ -49,7 +51,7 @@ ROOT_URLCONF = "ivatar.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"DIRS": [os.path.join(BASE_DIR, "templates")],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
@@ -57,7 +59,10 @@ TEMPLATES = [
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
"django.template.context_processors.i18n",
"social_django.context_processors.login_redirect",
],
"debug": DEBUG,
},
},
]
@@ -72,6 +77,7 @@ DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": os.path.join(BASE_DIR, "db.sqlite3"),
"ATOMIC_REQUESTS": True,
}
}
@@ -85,6 +91,9 @@ AUTH_PASSWORD_VALIDATORS = [
},
{
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", # noqa
"OPTIONS": {
"min_length": 6,
},
},
{
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", # noqa
@@ -94,6 +103,70 @@ AUTH_PASSWORD_VALIDATORS = [
},
]
# Password Hashing (more secure)
PASSWORD_HASHERS = [
# This isn't working in older Python environments
# "django.contrib.auth.hashers.Argon2PasswordHasher",
"django.contrib.auth.hashers.PBKDF2PasswordHasher",
"django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher",
]
# Security Settings
SECURE_BROWSER_XSS_FILTER = True
SECURE_CONTENT_TYPE_NOSNIFF = True
X_FRAME_OPTIONS = "DENY"
CSRF_COOKIE_SECURE = not DEBUG
SESSION_COOKIE_SECURE = not DEBUG
if not DEBUG:
SECURE_SSL_REDIRECT = True
SECURE_HSTS_SECONDS = 31536000 # 1 year
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True
# Social authentication
TRUST_EMAIL_FROM_SOCIAL_AUTH_BACKENDS = ["fedora"]
SOCIAL_AUTH_PIPELINE = (
# Get the information we can about the user and return it in a simple
# format to create the user instance later. In some cases the details are
# already part of the auth response from the provider, but sometimes this
# could hit a provider API.
"social_core.pipeline.social_auth.social_details",
# Get the social uid from whichever service we're authing thru. The uid is
# the unique identifier of the given user in the provider.
"social_core.pipeline.social_auth.social_uid",
# Verifies that the current auth process is valid within the current
# project, this is where emails and domains whitelists are applied (if
# defined).
"social_core.pipeline.social_auth.auth_allowed",
# Checks if the current social-account is already associated in the site.
"social_core.pipeline.social_auth.social_user",
# Make up a username for this person, appends a random string at the end if
# there's any collision.
"social_core.pipeline.user.get_username",
# Send a validation email to the user to verify its email address.
# Disabled by default.
# 'social_core.pipeline.mail.mail_validation',
# Associates the current social details with another user account with
# a similar email address. Disabled by default.
"social_core.pipeline.social_auth.associate_by_email",
# Associates the current social details with an existing user account with
# a matching ConfirmedEmail.
"ivatar.ivataraccount.auth.associate_by_confirmed_email",
# Create a user account if we haven't found one yet.
"social_core.pipeline.user.create_user",
# Create the record that associates the social account with the user.
"social_core.pipeline.social_auth.associate_user",
# Populate the extra_data field in the social record with the values
# specified by settings (and the default ones like access_token, etc).
"social_core.pipeline.social_auth.load_extra_data",
# Update the user record with any changed info from the auth service.
"social_core.pipeline.user.user_details",
# Create the ConfirmedEmail if appropriate.
"ivatar.ivataraccount.auth.add_confirmed_email",
)
# Internationalization
# https://docs.djangoproject.com/en/2.0/topics/i18n/
@@ -116,4 +189,4 @@ STATIC_ROOT = os.path.join(BASE_DIR, "static")
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
from config import * # pylint: disable=wildcard-import,wrong-import-position,unused-wildcard-import
from config import * # pylint: disable=wildcard-import,wrong-import-position,unused-wildcard-import # noqa

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

182
ivatar/tasks.py Normal file
View File

@@ -0,0 +1,182 @@
# -*- coding: utf-8 -*-
"""
Celery tasks for avatar generation
"""
import logging
from celery import shared_task
from django.contrib.auth.models import User
from io import BytesIO
from ivatar.ai_service import generate_avatar_image, AIServiceError
from ivatar.ivataraccount.models import GenerationTask, Photo
logger = logging.getLogger(__name__)
@shared_task(bind=True, name="ivatar.tasks.generate_avatar_task")
def generate_avatar_task(self, task_id, user_id, prompt, model, quality, allow_nsfw):
"""
Background task to generate avatar images
Args:
task_id: GenerationTask ID
user_id: User ID
prompt: Avatar description
model: AI model to use
quality: Generation quality
allow_nsfw: Whether to allow NSFW content
Returns:
dict: Task result with photo_id or error
"""
try:
# Get the task object
task = GenerationTask.objects.get(pk=task_id)
# Update task status
task.status = "processing"
task.task_id = self.request.id
task.progress = 10
task.save()
logger.info(f"Starting avatar generation task {task_id} for user {user_id}")
# Update progress
self.update_state(
state="PROGRESS",
meta={"progress": 20, "status": "Initializing generation..."},
)
task.progress = 20
task.save()
# Generate the avatar image
self.update_state(
state="PROGRESS", meta={"progress": 30, "status": "Generating image..."}
)
task.progress = 30
task.save()
generated_image = generate_avatar_image(
prompt=prompt,
model=model,
size=(512, 512),
quality=quality,
allow_nsfw=allow_nsfw,
)
# Update progress
self.update_state(
state="PROGRESS", meta={"progress": 70, "status": "Processing image..."}
)
task.progress = 70
task.save()
# Convert PIL image to bytes
img_buffer = BytesIO()
generated_image.save(img_buffer, format="PNG")
img_data = img_buffer.getvalue()
# Get user
user = User.objects.get(pk=user_id)
# Create Photo object
photo = Photo()
photo.user = user
photo.ip_address = "127.0.0.1" # Default IP for background tasks
photo.data = img_data
photo.format = "png"
# Store AI generation metadata
photo.ai_generated = True
photo.ai_prompt = prompt
photo.ai_model = model
photo.ai_quality = quality
photo.save()
# Update progress
self.update_state(
state="PROGRESS", meta={"progress": 90, "status": "Saving avatar..."}
)
task.progress = 90
task.save()
# Update task with completed status
task.status = "completed"
task.progress = 100
task.generated_photo = photo
task.save()
logger.info(
f"Completed avatar generation task {task_id}, created photo {photo.pk}"
)
return {"status": "completed", "photo_id": photo.pk, "task_id": task_id}
except AIServiceError as e:
logger.error(f"AI service error in task {task_id}: {e}")
task.status = "failed"
task.error_message = str(e)
task.progress = 0
task.save()
return {"status": "failed", "error": str(e), "task_id": task_id}
except Exception as e:
logger.error(f"Unexpected error in task {task_id}: {e}")
task.status = "failed"
task.error_message = str(e)
task.progress = 0
task.save()
return {
"status": "failed",
"error": f"Unexpected error: {str(e)}",
"task_id": task_id,
}
@shared_task(name="ivatar.tasks.update_queue_positions")
def update_queue_positions():
"""
Update queue positions for pending tasks
"""
try:
pending_tasks = GenerationTask.objects.filter(status="pending").order_by(
"add_date"
)
for index, task in enumerate(pending_tasks):
task.queue_position = index + 1
task.save()
logger.info(f"Updated queue positions for {len(pending_tasks)} pending tasks")
except Exception as e:
logger.error(f"Error updating queue positions: {e}")
@shared_task(name="ivatar.tasks.cleanup_old_tasks")
def cleanup_old_tasks():
"""
Clean up old completed/failed tasks
"""
try:
from django.utils import timezone
from datetime import timedelta
# Delete tasks older than 7 days
cutoff_date = timezone.now() - timedelta(days=7)
old_tasks = GenerationTask.objects.filter(
add_date__lt=cutoff_date, status__in=["completed", "failed", "cancelled"]
)
count = old_tasks.count()
old_tasks.delete()
logger.info(f"Cleaned up {count} old tasks")
except Exception as e:
logger.error(f"Error cleaning up old tasks: {e}")

View File

@@ -37,6 +37,7 @@ class Tester(TestCase):
self.assertEqual(pil_format("jpeg"), "JPEG")
self.assertEqual(pil_format("png"), "PNG")
self.assertEqual(pil_format("gif"), "GIF")
self.assertEqual(pil_format("webp"), "WEBP")
self.assertEqual(pil_format("abc"), None)
def test_userprefs_str(self):

View File

@@ -2,16 +2,23 @@
"""
Test our views in ivatar.ivataraccount.views and ivatar.views
"""
import contextlib
# pylint: disable=too-many-lines
import os
import json
import django
from django.urls import reverse
from django.test import TestCase
from django.test import Client
from django.contrib.auth.models import User
from ivatar.utils import random_string, Bluesky
from ivatar.utils import random_string
BLUESKY_APP_PASSWORD = None
BLUESKY_IDENTIFIER = None
with contextlib.suppress(Exception):
from settings import BLUESKY_APP_PASSWORD, BLUESKY_IDENTIFIER
os.environ["DJANGO_SETTINGS_MODULE"] = "ivatar.settings"
django.setup()
@@ -49,12 +56,17 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
"""
Test incorrect digest
"""
response = self.client.get("/avatar/%s" % "x" * 65, follow=True)
self.assertRedirects(
response=response,
expected_url="/static/img/deadbeef.png",
msg_prefix="Why does an invalid hash not redirect to deadbeef?",
response = self.client.get("/avatar/" + "x" * 65, follow=True)
self.assertEqual(
response.redirect_chain[2][0],
"/static/img/nobody/80.png",
"Doesn't redirect to static?",
)
# self.assertRedirects(
# response=response,
# expected_url="/static/img/nobody/80.png",
# msg_prefix="Why does an invalid hash not redirect to deadbeef?",
# )
def test_stats(self):
"""
@@ -71,3 +83,31 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
j["unconfirmed_openids"], 0, "unconfirmed openids count incorrect"
)
self.assertEqual(j["avatars"], 0, "avatars count incorrect")
def test_logout(self):
"""
Test if logout works correctly
"""
self.login()
response = self.client.get(reverse("logout"), follow=True)
self.assertEqual(
response.status_code, 405, "logout with get should lead to http error 405"
)
response = self.client.post(reverse("logout"), follow=True)
self.assertEqual(response.status_code, 200, "logout with post should logout")
def test_Bluesky_client(self):
"""
Bluesky client needs credentials, so it's limited with testing here now
"""
if BLUESKY_APP_PASSWORD and BLUESKY_IDENTIFIER:
b = Bluesky()
profile = b.get_profile("libravatar.org")
self.assertEqual(profile["handle"], "libravata.org")
# As long as I don't change my avatar, this should stay the same
self.assertEqual(
profile["avatar"],
"https://cdn.bsky.app/img/avatar/plain/did:plc:35jdu26cjgsc5vdbsaqiuw4a/bafkreidgtubihcdwcr72s5nag2ohcnwhhbg2zabw4jtxlhmtekrm6t5f4y@jpeg",
)
self.assertEqual(True, True)

0
ivatar/tools/__init__.py Normal file
View File

View File

@@ -66,38 +66,37 @@
{% endfor %}
{% endif %}
<div style="max-width:640px">
<div class="form-container">
<form method="post" name="check">
{% csrf_token %}
<div class="form-group"><label for="id_mail">{% trans 'E-Mail' %}</label>
<input type="email" name="mail" maxlength="254" minlength="6" class="form-control" placeholder="{% trans 'E-Mail' %}" {% if form.mail.value %} value="{{ form.mail.value }}" {% endif %} id="id_mail"></div>
<div class="form-group"><label for="id_openid">{% trans 'OpenID' %}</label>
<input type="text" name="openid" maxlength="255" minlength="11" class="form-control" placeholder="{% trans 'OpenID' %}" {% if form.openid.value %} value="{{ form.openid.value }}" {% endif %} id="id_openid"></div>
<div class="form-group"><label for="id_size">{% trans 'Size' %}</label>
<input type="number" name="size" min="5" max="512" class="form-control" placeholder="{% trans 'Size' %}" {% if form.size.value %} value="{{ form.size.value }}" {% else %} value="100" {% endif %} required id="id_size"></div>
{% if form.default_url.errors %}
<div class="alert alert-danger" role="alert">{{ form.default_url.errors }}</div>
{% endif %}
<div class="form-group"><label for="id_default_url">{% trans 'Default URL or special keyword' %}</label>
<input type="text" name="default_url" class="form-control" placeholder="{% trans 'Default' %}" {% if form.default_url.value %} value="{{ form.default_url.value }}" {% endif %} id="id_default_url"></div>
{% if form.default_opt.errors %}
<div class="alert alert-danger" role="alert">{{ form.default_opt.errors }}</div>
{% endif %}
<div class="form-group"><label for="id_default_opt">{% trans 'Default (special keyword)' %}</label>
<div class="form-group">
<label for="id_mail" class="form-label">{% trans 'E-Mail' %}</label>
<input type="email" name="mail" maxlength="254" minlength="6" class="form-control" placeholder="{% trans 'E-Mail' %}" {% if form.mail.value %} value="{{ form.mail.value }}" {% endif %} id="id_mail">
</div>
<div class="form-group">
<label for="id_openid" class="form-label">{% trans 'OpenID' %}</label>
<input type="text" name="openid" maxlength="255" minlength="11" class="form-control" placeholder="{% trans 'OpenID' %}" {% if form.openid.value %} value="{{ form.openid.value }}" {% endif %} id="id_openid">
</div>
<div class="form-group">
<label for="id_size" class="form-label">{% trans 'Size' %}</label>
<input type="number" name="size" min="5" max="512" class="form-control" placeholder="{% trans 'Size' %}" {% if form.size.value %} value="{{ form.size.value }}" {% else %} value="100" {% endif %} required id="id_size">
</div>
<div class="form-group">
<label for="id_default_url" class="form-label">{% trans 'Default URL or special keyword' %}</label>
<input type="text" name="default_url" class="form-control" placeholder="{% trans 'Default' %}" {% if form.default_url.value %} value="{{ form.default_url.value }}" {% endif %} id="id_default_url">
</div>
<div class="form-group">
<label class="form-label">{% trans 'Default (special keyword)' %}</label>
{% for opt in form.default_opt.field.choices %}
<div class="radio" {% if forloop.counter|divisibleby:2 %}even{% else %}odd{% endif %}>
<input type="radio" name="default_opt" value="{{ opt.0 }}"
id="default_opt-{{ opt.0 }}"
{% if form.default_opt.value == opt.0 %}checked{% endif %}
>
<label for="default_opt-{{ opt.0 }}">{{ opt.1 }}</label>
<div class="form-check">
<input type="radio" name="default_opt" value="{{ opt.0 }}" class="form-check-input" id="default_opt-{{ opt.0 }}" {% if form.default_opt.value == opt.0 %}checked{% endif %}>
<label for="default_opt-{{ opt.0 }}" class="form-check-label">{{ opt.1 }}</label>
</div>
{% endfor %}
</div>
<div class="form-group">
<button type="submit" class="button">{% trans 'Check' %}</button>
<div class="button-group">
<button type="submit" class="btn btn-primary">{% trans 'Check' %}</button>
</div>
</form>
</div>

View File

@@ -48,16 +48,109 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
password=self.password,
)
def test_check(self):
def test_check_mail(self):
"""
Test check page
"""
self.login()
response = self.client.get(reverse("tools_check"))
self.assertEqual(response.status_code, 200, "no 200 ok?")
response = self.client.post(
reverse("tools_check"),
data={"mail": "test@test.com", "size": "85"},
follow=True,
)
self.assertContains(
response,
'value="test@test.com"',
1,
200,
"Value not set again!?",
)
self.assertContains(
response,
"b642b4217b34b1e8d3bd915fc65c4452",
3,
200,
"Wrong md5 hash!?",
)
self.assertContains(
response,
"f660ab912ec121d1b1e928a0bb4bc61b15f5ad44d5efdc4e1c92a25e99b8e44a",
3,
200,
"Wrong sha256 hash!?",
)
self.assertContains(
response,
'value="85"',
1,
200,
"Size should be set based on post params!?",
)
def test_check_openid(self):
"""
Test check page
"""
self.login()
response = self.client.get(reverse("tools_check"))
self.assertEqual(response.status_code, 200, "no 200 ok?")
response = self.client.post(
reverse("tools_check"),
data={"openid": "https://test.com", "size": "85"},
follow=True,
)
self.assertContains(
response,
'value="https://test.com"',
1,
200,
"Value not set again!?",
)
self.assertContains(
response,
"396936bd0bf0603d6784b65d03e96dae90566c36b62661f28d4116c516524bcc",
3,
200,
"Wrong sha256 hash!?",
)
self.assertContains(
response,
'value="85"',
1,
200,
"Size should be set based on post params!?",
)
def test_check_domain(self):
"""
Test check domain page
"""
self.login()
response = self.client.get(reverse("tools_check_domain"))
self.assertEqual(response.status_code, 200, "no 200 ok?")
response = self.client.post(
reverse("tools_check_domain"),
data={"domain": "linux-kernel.at"},
follow=True,
)
self.assertEqual(response.status_code, 200, "no 200 ok?")
self.assertContains(
response,
"http://avatars.linux-kernel.at",
2,
200,
"Not responing with right URL!?",
)
self.assertContains(
response,
"https://avatars.linux-kernel.at",
2,
200,
"Not responing with right URL!?",
)

View File

@@ -3,11 +3,11 @@
ivatar/tools URL configuration
"""
from django.conf.urls import url
from django.urls import path, re_path
from .views import CheckView, CheckDomainView
urlpatterns = [ # pylint: disable=invalid-name
url("check/", CheckView.as_view(), name="tools_check"),
url("check_domain/", CheckDomainView.as_view(), name="tools_check_domain"),
url("check_domain$", CheckDomainView.as_view(), name="tools_check_domain"),
path("check/", CheckView.as_view(), name="tools_check"),
path("check_domain/", CheckDomainView.as_view(), name="tools_check_domain"),
re_path("check_domain$", CheckDomainView.as_view(), name="tools_check_domain"),
]

View File

@@ -16,7 +16,7 @@ from libravatar import libravatar_url, parse_user_identity
from libravatar import SECURE_BASE_URL as LIBRAVATAR_SECURE_BASE_URL
from libravatar import BASE_URL as LIBRAVATAR_BASE_URL
from ivatar.settings import SECURE_BASE_URL, BASE_URL
from ivatar.settings import SECURE_BASE_URL, BASE_URL, SITE_NAME, DEBUG
from .forms import (
CheckDomainForm,
CheckForm,
@@ -33,10 +33,9 @@ class CheckDomainView(FormView):
success_url = reverse("tools_check_domain")
def form_valid(self, form):
result = {}
super().form_valid(form)
domain = form.cleaned_data["domain"]
result["avatar_server_http"] = lookup_avatar_server(domain, False)
result = {"avatar_server_http": lookup_avatar_server(domain, False)}
if result["avatar_server_http"]:
result["avatar_server_http_ipv4"] = lookup_ip_address(
result["avatar_server_http"], False
@@ -80,8 +79,6 @@ class CheckView(FormView):
mail_hash = None
mail_hash256 = None
openid_hash = None
size = 80
super().form_valid(form)
if form.cleaned_data["default_url"]:
@@ -94,8 +91,7 @@ class CheckView(FormView):
else:
default_url = None
if "size" in form.cleaned_data:
size = form.cleaned_data["size"]
size = form.cleaned_data["size"] if "size" in form.cleaned_data else 80
if form.cleaned_data["mail"]:
mailurl = libravatar_url(
email=form.cleaned_data["mail"], size=size, default=default_url
@@ -121,7 +117,7 @@ class CheckView(FormView):
if not form.cleaned_data["openid"].startswith(
"http://"
) and not form.cleaned_data["openid"].startswith("https://"):
form.cleaned_data["openid"] = "http://%s" % form.cleaned_data["openid"]
form.cleaned_data["openid"] = f'http://{form.cleaned_data["openid"]}'
openidurl = libravatar_url(
openid=form.cleaned_data["openid"], size=size, default=default_url
)
@@ -139,6 +135,35 @@ class CheckView(FormView):
openid=form.cleaned_data["openid"], email=None
)[0]
if "DEVELOPMENT" in SITE_NAME and DEBUG:
if mailurl:
mailurl = mailurl.replace(
"https://avatars.linux-kernel.at",
f"http://{self.request.get_host()}",
)
if mailurl_secure:
mailurl_secure = mailurl_secure.replace(
"https://avatars.linux-kernel.at",
f"http://{self.request.get_host()}",
)
if mailurl_secure_256:
mailurl_secure_256 = mailurl_secure_256.replace(
"https://avatars.linux-kernel.at",
f"http://{self.request.get_host()}",
)
if openidurl:
openidurl = openidurl.replace(
"https://avatars.linux-kernel.at",
f"http://{self.request.get_host()}",
)
if openidurl_secure:
openidurl_secure = openidurl_secure.replace(
"https://avatars.linux-kernel.at",
f"http://{self.request.get_host()}",
)
print(mailurl, openidurl, mailurl_secure, mailurl_secure_256, openidurl_secure)
return render(
self.request,
self.template_name,
@@ -172,15 +197,15 @@ def lookup_avatar_server(domain, https):
service_name = None
if https:
service_name = "_avatars-sec._tcp.%s" % domain
service_name = f"_avatars-sec._tcp.{domain}"
else:
service_name = "_avatars._tcp.%s" % domain
service_name = f"_avatars._tcp.{domain}"
DNS.DiscoverNameServers()
try:
dns_request = DNS.Request(name=service_name, qtype="SRV").req()
except DNS.DNSError as message:
print("DNS Error: %s (%s)" % (message, domain))
print(f"DNS Error: {message} ({domain})")
return None
if dns_request.header["status"] == "NXDOMAIN":
@@ -188,7 +213,7 @@ def lookup_avatar_server(domain, https):
return None
if dns_request.header["status"] != "NOERROR":
print("DNS Error: status=%s (%s)" % (dns_request.header["status"], domain))
print(f'DNS Error: status={dns_request.header["status"]} ({domain})')
return None
records = []
@@ -213,7 +238,7 @@ def lookup_avatar_server(domain, https):
target, port = srv_hostname(records)
if target and ((https and port != 443) or (not https and port != 80)):
return "%s:%s" % (target, port)
return f"{target}:{port}"
return target
@@ -243,7 +268,7 @@ def srv_hostname(records):
# Take care - this if is only a if, if the above if
# uses continue at the end. else it should be an elsif
if ret["priority"] < top_priority:
# reset the aretay (ret has higher priority)
# reset the priority (ret has higher priority)
top_priority = ret["priority"]
total_weight = 0
priority_records = []
@@ -253,7 +278,7 @@ def srv_hostname(records):
if ret["weight"] > 0:
priority_records.append((total_weight, ret))
else:
# zero-weigth elements must come first
# zero-weight elements must come first
priority_records.insert(0, (0, ret))
if len(priority_records) == 1:
@@ -285,11 +310,11 @@ def lookup_ip_address(hostname, ipv6):
else:
dns_request = DNS.Request(name=hostname, qtype=DNS.Type.A).req()
except DNS.DNSError as message:
print("DNS Error: %s (%s)" % (message, hostname))
print(f"DNS Error: {message} ({hostname})")
return None
if dns_request.header["status"] != "NOERROR":
print("DNS Error: status=%s (%s)" % (dns_request.header["status"], hostname))
print(f'DNS Error: status={dns_request.header["status"]} ({hostname})')
return None
for answer in dns_request.answers:
@@ -300,9 +325,5 @@ def lookup_ip_address(hostname, ipv6):
):
continue # skip CNAME records
if ipv6:
return inet_ntop(AF_INET6, answer["data"])
return answer["data"]
return inet_ntop(AF_INET6, answer["data"]) if ipv6 else answer["data"]
return None

View File

@@ -2,76 +2,88 @@
"""
ivatar URL configuration
"""
import contextlib
from django.contrib import admin
from django.urls import path, include
from django.conf.urls import url
from django.urls import path, include, re_path
from django.conf.urls.static import static
from django.views.generic import TemplateView, RedirectView
from ivatar import settings
from .views import AvatarImageView, GravatarProxyView, StatsView
from .views import AvatarImageView, StatsView
from .views import GravatarProxyView, BlueskyProxyView
urlpatterns = [ # pylint: disable=invalid-name
path("admin/", admin.site.urls),
path("i18n/", include("django.conf.urls.i18n")),
url("openid/", include("django_openid_auth.urls")),
url("tools/", include("ivatar.tools.urls")),
url(r"avatar/(?P<digest>\w{64})", AvatarImageView.as_view(), name="avatar_view"),
url(r"avatar/(?P<digest>\w{32})", AvatarImageView.as_view(), name="avatar_view"),
url(r"avatar/$", AvatarImageView.as_view(), name="avatar_view"),
url(
path("openid/", include("django_openid_auth.urls")),
path("auth/", include("social_django.urls", namespace="social")),
path("tools/", include("ivatar.tools.urls")),
re_path(
r"avatar/(?P<digest>\w{64})", AvatarImageView.as_view(), name="avatar_view"
),
re_path(
r"avatar/(?P<digest>\w{32})", AvatarImageView.as_view(), name="avatar_view"
),
re_path(r"avatar/$", AvatarImageView.as_view(), name="avatar_view"),
re_path(
r"avatar/(?P<digest>\w*)",
RedirectView.as_view(url="/static/img/deadbeef.png"),
name="invalid_hash",
),
url(
re_path(
r"gravatarproxy/(?P<digest>\w*)",
GravatarProxyView.as_view(),
name="gravatarproxy",
),
url(
re_path(
r"blueskyproxy/(?P<digest>\w*)",
BlueskyProxyView.as_view(),
name="blueskyproxy",
),
path(
"description/",
TemplateView.as_view(template_name="description.html"),
name="description",
),
# The following two are TODO TODO TODO TODO TODO
url(
path(
"run_your_own/",
TemplateView.as_view(template_name="run_your_own.html"),
name="run_your_own",
),
url(
path(
"features/",
TemplateView.as_view(template_name="features.html"),
name="features",
),
url(
path(
"security/",
TemplateView.as_view(template_name="security.html"),
name="security",
),
url("privacy/", TemplateView.as_view(template_name="privacy.html"), name="privacy"),
url("contact/", TemplateView.as_view(template_name="contact.html"), name="contact"),
path(
"privacy/", TemplateView.as_view(template_name="privacy.html"), name="privacy"
),
path(
"contact/", TemplateView.as_view(template_name="contact.html"), name="contact"
),
path("talk_to_us/", RedirectView.as_view(url="/contact"), name="talk_to_us"),
url("stats/", StatsView.as_view(), name="stats"),
path("stats/", StatsView.as_view(), name="stats"),
]
MAINTENANCE = False
try:
with contextlib.suppress(Exception):
if settings.MAINTENANCE:
MAINTENANCE = True
except: # pylint: disable=bare-except
pass
if MAINTENANCE:
urlpatterns.append(
url("", TemplateView.as_view(template_name="maintenance.html"), name="home")
path("", TemplateView.as_view(template_name="maintenance.html"), name="home")
)
urlpatterns.insert(3, url("accounts/", RedirectView.as_view(url="/")))
urlpatterns.insert(3, path("accounts/", RedirectView.as_view(url="/")))
else:
urlpatterns.append(
url("", TemplateView.as_view(template_name="home.html"), name="home")
path("", TemplateView.as_view(template_name="home.html"), name="home")
)
urlpatterns.insert(3, url("accounts/", include("ivatar.ivataraccount.urls")))
urlpatterns.insert(3, path("accounts/", include("ivatar.ivataraccount.urls")))
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)

View File

@@ -2,10 +2,103 @@
"""
Simple module providing reusable random_string function
"""
import contextlib
import random
import string
from PIL import Image, ImageDraw
from io import BytesIO
from PIL import Image, ImageDraw, ImageSequence
from urllib.parse import urlparse
import requests
from ivatar.settings import DEBUG, URL_TIMEOUT
from urllib.request import urlopen as urlopen_orig
BLUESKY_IDENTIFIER = None
BLUESKY_APP_PASSWORD = None
with contextlib.suppress(Exception):
from ivatar.settings import BLUESKY_IDENTIFIER, BLUESKY_APP_PASSWORD
def urlopen(url, timeout=URL_TIMEOUT):
ctx = None
if DEBUG:
import ssl
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
return urlopen_orig(url, timeout=timeout, context=ctx)
class Bluesky:
"""
Handle Bluesky client access
"""
identifier = ""
app_password = ""
service = "https://bsky.social"
session = None
def __init__(
self,
identifier: str = BLUESKY_IDENTIFIER,
app_password: str = BLUESKY_APP_PASSWORD,
service: str = "https://bsky.social",
):
self.identifier = identifier
self.app_password = app_password
self.service = service
def login(self):
"""
Login to Bluesky
"""
auth_response = requests.post(
f"{self.service}/xrpc/com.atproto.server.createSession",
json={"identifier": self.identifier, "password": self.app_password},
)
auth_response.raise_for_status()
self.session = auth_response.json()
def normalize_handle(self, handle: str) -> str:
"""
Return the normalized handle for given handle
"""
# Normalize Bluesky handle in case someone enters an '@' at the beginning
while handle.startswith("@"):
handle = handle[1:]
# Remove trailing spaces or spaces at the beginning
while handle.startswith(" "):
handle = handle[1:]
while handle.endswith(" "):
handle = handle[:-1]
return handle
def get_profile(self, handle: str) -> str:
if not self.session:
self.login()
profile_response = None
try:
profile_response = requests.get(
f"{self.service}/xrpc/app.bsky.actor.getProfile",
headers={"Authorization": f'Bearer {self.session["accessJwt"]}'},
params={"actor": handle},
)
profile_response.raise_for_status()
except Exception as exc:
print(f"Bluesky profile fetch failed with HTTP error: {exc}")
return None
return profile_response.json()
def get_avatar(self, handle: str):
"""
Get avatar URL for a handle
"""
profile = self.get_profile(handle)
return profile["avatar"] if profile else None
def random_string(length=10):
@@ -31,12 +124,12 @@ def openid_variations(openid):
if openid.startswith("https://"):
openid = openid.replace("https://", "http://")
if openid[-1] != "/":
openid = openid + "/"
openid = f"{openid}/"
# http w/o trailing slash
var1 = openid[0:-1]
var1 = openid[:-1]
var2 = openid.replace("http://", "https://")
var3 = var2[0:-1]
var3 = var2[:-1]
return (openid, var1, var2, var3)
@@ -54,43 +147,43 @@ def mm_ng(
idhash = "e0"
# How large is the circle?
circlesize = size * 0.6
circle_size = size * 0.6
# Coordinates for the circle
start_x = int(size * 0.2)
end_x = start_x + circlesize
end_x = start_x + circle_size
start_y = int(size * 0.05)
end_y = start_y + circlesize
end_y = start_y + circle_size
# All are the same, based on the input hash
# this should always result in a "gray-ish" background
red = idhash[0:2]
green = idhash[0:2]
blue = idhash[0:2]
red = idhash[:2]
green = idhash[:2]
blue = idhash[:2]
# Add some red (i/a) and make sure it's not over 255
red = hex(int(red, 16) + add_red).replace("0x", "")
if int(red, 16) > 255:
red = "ff"
if len(red) == 1:
red = "0%s" % red
red = f"0{red}"
# Add some green (i/a) and make sure it's not over 255
green = hex(int(green, 16) + add_green).replace("0x", "")
if int(green, 16) > 255:
green = "ff"
if len(green) == 1:
green = "0%s" % green
green = f"0{green}"
# Add some blue (i/a) and make sure it's not over 255
blue = hex(int(blue, 16) + add_blue).replace("0x", "")
if int(blue, 16) > 255:
blue = "ff"
if len(blue) == 1:
blue = "0%s" % blue
blue = f"0{blue}"
# Assemable the bg color "string" in webnotation. Eg. '#d3d3d3'
bg_color = "#" + red + green + blue
# Assemble the bg color "string" in web notation. Eg. '#d3d3d3'
bg_color = f"#{red}{green}{blue}"
# Image
image = Image.new("RGB", (size, size))
@@ -105,7 +198,7 @@ def mm_ng(
# Draw MMs 'body'
draw.polygon(
(
(start_x + circlesize / 2, size / 2.5),
(start_x + circle_size / 2, size / 2.5),
(size * 0.15, size),
(size - size * 0.15, size),
),
@@ -124,33 +217,33 @@ def is_trusted_url(url, url_filters):
"""
(scheme, netloc, path, params, query, fragment) = urlparse(url)
for filter in url_filters:
if "schemes" in filter:
schemes = filter["schemes"]
for ufilter in url_filters:
if "schemes" in ufilter:
schemes = ufilter["schemes"]
if scheme not in schemes:
continue
if "host_equals" in filter:
host_equals = filter["host_equals"]
if "host_equals" in ufilter:
host_equals = ufilter["host_equals"]
if netloc != host_equals:
continue
if "host_suffix" in filter:
host_suffix = filter["host_suffix"]
if "host_suffix" in ufilter:
host_suffix = ufilter["host_suffix"]
if not netloc.endswith(host_suffix):
continue
if "path_prefix" in filter:
path_prefix = filter["path_prefix"]
if "path_prefix" in ufilter:
path_prefix = ufilter["path_prefix"]
if not path.startswith(path_prefix):
continue
if "url_prefix" in filter:
url_prefix = filter["url_prefix"]
if "url_prefix" in ufilter:
url_prefix = ufilter["url_prefix"]
if not url.startswith(url_prefix):
continue
@@ -158,3 +251,25 @@ def is_trusted_url(url, url_filters):
return True
return False
def resize_animated_gif(input_pil: Image, size: list) -> BytesIO:
def _thumbnail_frames(image):
for frame in ImageSequence.Iterator(image):
new_frame = frame.copy()
new_frame.thumbnail(size)
yield new_frame
frames = list(_thumbnail_frames(input_pil))
output = BytesIO()
output_image = frames[0]
output_image.save(
output,
format="gif",
save_all=True,
optimize=False,
append_images=frames[1:],
disposal=input_pil.disposal_method,
**input_pil.info,
)
return output

View File

@@ -2,10 +2,12 @@
"""
views under /
"""
import contextlib
from io import BytesIO
from os import path
import hashlib
from urllib.request import urlopen
from ivatar.utils import urlopen, Bluesky
from urllib.error import HTTPError, URLError
from ssl import SSLError
from django.views.generic.base import TemplateView, View
@@ -34,9 +36,7 @@ from .ivataraccount.models import ConfirmedEmail, ConfirmedOpenId
from .ivataraccount.models import UnconfirmedEmail, UnconfirmedOpenId
from .ivataraccount.models import Photo
from .ivataraccount.models import pil_format, file_format
from .utils import is_trusted_url, mm_ng
URL_TIMEOUT = 5 # in seconds
from .utils import is_trusted_url, mm_ng, resize_animated_gif
def get_size(request, size=DEFAULT_AVATAR_SIZE):
@@ -49,17 +49,11 @@ def get_size(request, size=DEFAULT_AVATAR_SIZE):
if "size" in request.GET:
sizetemp = request.GET["size"]
if sizetemp:
if sizetemp != "" and sizetemp is not None and sizetemp != "0":
try:
if sizetemp not in ["", "0"]:
with contextlib.suppress(ValueError):
if int(sizetemp) > 0:
size = int(sizetemp)
# Should we receive something we cannot convert to int, leave
# the user with the default value of 80
except ValueError:
pass
if size > int(AVATAR_MAX_SIZE):
size = int(AVATAR_MAX_SIZE)
size = min(size, int(AVATAR_MAX_SIZE))
return size
@@ -121,9 +115,9 @@ class AvatarImageView(TemplateView):
# Check the cache first
if CACHE_RESPONSE:
centry = caches["filesystem"].get(uri)
if centry:
# For DEBUG purpose only print('Cached entry for %s' % uri)
if centry := caches["filesystem"].get(uri):
# For DEBUG purpose only
# print('Cached entry for %s' % uri)
return HttpResponse(
centry["content"],
content_type=centry["content_type"],
@@ -151,7 +145,7 @@ class AvatarImageView(TemplateView):
if not trusted_url:
print(
"Default URL is not in trusted URLs: '%s' ; Kicking it!" % default
f"Default URL is not in trusted URLs: '{default}'; Kicking it!"
)
default = None
@@ -177,20 +171,23 @@ class AvatarImageView(TemplateView):
obj = model.objects.get(digest_sha256=kwargs["digest"])
except ObjectDoesNotExist:
model = ConfirmedOpenId
try:
with contextlib.suppress(Exception):
d = kwargs["digest"] # pylint: disable=invalid-name
# OpenID is tricky. http vs. https, versus trailing slash or not
# However, some users eventually have added their variations already
# and therfore we need to use filter() and first()
# and therefore we need to use filter() and first()
obj = model.objects.filter(
Q(digest=d)
| Q(alt_digest1=d)
| Q(alt_digest2=d)
| Q(alt_digest3=d)
).first()
except Exception: # pylint: disable=bare-except
pass
# Handle the special case of Bluesky
if obj:
if obj.bluesky_handle:
return HttpResponseRedirect(
reverse_lazy("blueskyproxy", args=[kwargs["digest"]])
)
# If that mail/openid doesn't exist, or has no photo linked to it
if not obj or not obj.photo or forcedefault:
gravatar_url = (
@@ -212,7 +209,7 @@ class AvatarImageView(TemplateView):
)
# Ensure we do not convert None to string 'None'
if default:
url += "&default=%s" % default
url += f"&default={default}"
return HttpResponseRedirect(url)
# Return the default URL, as specified, or 404 Not Found, if default=404
@@ -222,7 +219,7 @@ class AvatarImageView(TemplateView):
url = (
reverse_lazy("gravatarproxy", args=[kwargs["digest"]])
+ "?s=%i" % size
+ "&default=%s&f=y" % default
+ f"&default={default}&f=y"
)
return HttpResponseRedirect(url)
@@ -232,46 +229,25 @@ class AvatarImageView(TemplateView):
if str(default) == "monsterid":
monsterdata = BuildMonster(seed=kwargs["digest"], size=(size, size))
data = BytesIO()
monsterdata.save(data, "PNG", quality=JPEG_QUALITY)
data.seek(0)
response = CachingHttpResponse(uri, data, content_type="image/png")
response["Cache-Control"] = "max-age=%i" % CACHE_IMAGES_MAX_AGE
return response
return self._return_cached_png(monsterdata, data, uri)
if str(default) == "robohash":
roboset = "any"
if request.GET.get("robohash"):
roboset = request.GET.get("robohash")
roboset = request.GET.get("robohash") or "any"
robohash = Robohash(kwargs["digest"])
robohash.assemble(roboset=roboset, sizex=size, sizey=size)
data = BytesIO()
robohash.img.save(data, format="png")
data.seek(0)
response = CachingHttpResponse(uri, data, content_type="image/png")
response["Cache-Control"] = "max-age=%i" % CACHE_IMAGES_MAX_AGE
return response
return self._return_cached_response(data, uri)
if str(default) == "retro":
identicon = Identicon.render(kwargs["digest"])
data = BytesIO()
img = Image.open(BytesIO(identicon))
img = img.resize((size, size), Image.ANTIALIAS)
img.save(data, "PNG", quality=JPEG_QUALITY)
data.seek(0)
response = CachingHttpResponse(uri, data, content_type="image/png")
response["Cache-Control"] = "max-age=%i" % CACHE_IMAGES_MAX_AGE
return response
img = img.resize((size, size), Image.LANCZOS)
return self._return_cached_png(img, data, uri)
if str(default) == "pagan":
paganobj = pagan.Avatar(kwargs["digest"])
data = BytesIO()
img = paganobj.img.resize((size, size), Image.ANTIALIAS)
img.save(data, "PNG", quality=JPEG_QUALITY)
data.seek(0)
response = CachingHttpResponse(uri, data, content_type="image/png")
response["Cache-Control"] = "max-age=%i" % CACHE_IMAGES_MAX_AGE
return response
img = paganobj.img.resize((size, size), Image.LANCZOS)
return self._return_cached_png(img, data, uri)
if str(default) == "identicon":
p = Pydenticon5() # pylint: disable=invalid-name
# In order to make use of the whole 32 bytes digest, we need to redigest them.
@@ -280,52 +256,35 @@ class AvatarImageView(TemplateView):
).hexdigest()
img = p.draw(newdigest, size, 0)
data = BytesIO()
img.save(data, "PNG", quality=JPEG_QUALITY)
data.seek(0)
response = CachingHttpResponse(uri, data, content_type="image/png")
response["Cache-Control"] = "max-age=%i" % CACHE_IMAGES_MAX_AGE
return response
return self._return_cached_png(img, data, uri)
if str(default) == "mmng":
mmngimg = mm_ng(idhash=kwargs["digest"], size=size)
data = BytesIO()
mmngimg.save(data, "PNG", quality=JPEG_QUALITY)
data.seek(0)
response = CachingHttpResponse(uri, data, content_type="image/png")
response["Cache-Control"] = "max-age=%i" % CACHE_IMAGES_MAX_AGE
return response
if str(default) == "mm" or str(default) == "mp":
# If mm is explicitly given, we need to catch that
static_img = path.join(
"static", "img", "mm", "%s%s" % (str(size), ".png")
)
if not path.isfile(static_img):
# We trust this exists!!!
static_img = path.join("static", "img", "mm", "512.png")
# We trust static/ is mapped to /static/
return HttpResponseRedirect("/" + static_img)
return self._return_cached_png(mmngimg, data, uri)
if str(default) in {"mm", "mp"}:
return self._redirect_static_w_size("mm", size)
return HttpResponseRedirect(default)
static_img = path.join(
"static", "img", "nobody", "%s%s" % (str(size), ".png")
)
if not path.isfile(static_img):
# We trust this exists!!!
static_img = path.join("static", "img", "nobody", "512.png")
# We trust static/ is mapped to /static/
return HttpResponseRedirect("/" + static_img)
return self._redirect_static_w_size("nobody", size)
imgformat = obj.photo.format
photodata = Image.open(BytesIO(obj.photo.data))
data = BytesIO()
# Animated GIFs need additional handling
if imgformat == "gif" and photodata.is_animated:
# Debug only
# print("Object is animated and has %i frames" % photodata.n_frames)
data = resize_animated_gif(photodata, (size, size))
else:
# If the image is smaller than what was requested, we need
# to use the function resize
if photodata.size[0] < size or photodata.size[1] < size:
photodata = photodata.resize((size, size), Image.ANTIALIAS)
photodata = photodata.resize((size, size), Image.LANCZOS)
else:
photodata.thumbnail((size, size), Image.ANTIALIAS)
data = BytesIO()
photodata.thumbnail((size, size), Image.LANCZOS)
photodata.save(data, pil_format(imgformat), quality=JPEG_QUALITY)
data.seek(0)
obj.photo.access_count += 1
obj.photo.save()
@@ -333,10 +292,36 @@ class AvatarImageView(TemplateView):
obj.save()
if imgformat == "jpg":
imgformat = "jpeg"
response = CachingHttpResponse(uri, data, content_type="image/%s" % imgformat)
response = CachingHttpResponse(uri, data, content_type=f"image/{imgformat}")
response["Cache-Control"] = "max-age=%i" % CACHE_IMAGES_MAX_AGE
# Remove Vary header for images since language doesn't matter
response["Vary"] = ""
return response
def _redirect_static_w_size(self, arg0, size):
"""
Helper method to redirect to static image with size i/a
"""
# If mm is explicitly given, we need to catch that
static_img = path.join("static", "img", arg0, f"{str(size)}.png")
if not path.isfile(static_img):
# We trust this exists!!!
static_img = path.join("static", "img", arg0, "512.png")
# We trust static/ is mapped to /static/
return HttpResponseRedirect(f"/{static_img}")
def _return_cached_response(self, data, uri):
data.seek(0)
response = CachingHttpResponse(uri, data, content_type="image/png")
response["Cache-Control"] = "max-age=%i" % CACHE_IMAGES_MAX_AGE
# Remove Vary header for images since language doesn't matter
response["Vary"] = ""
return response
def _return_cached_png(self, arg0, data, uri):
arg0.save(data, "PNG", quality=JPEG_QUALITY)
return self._return_cached_response(data, uri)
class GravatarProxyView(View):
"""
@@ -359,19 +344,16 @@ class GravatarProxyView(View):
+ "&forcedefault=y"
)
if default is not None:
url += "&default=%s" % default
url += f"&default={default}"
return HttpResponseRedirect(url)
size = get_size(request)
gravatarimagedata = None
default = None
try:
with contextlib.suppress(Exception):
if str(request.GET["default"]) != "None":
default = request.GET["default"]
except Exception: # pylint: disable=bare-except
pass
if str(default) != "wavatar":
# This part is special/hackish
# Check if the image returned by Gravatar is their default image, if so,
@@ -386,40 +368,39 @@ class GravatarProxyView(View):
# print("Cached Gravatar response: Default.")
return redir_default(default)
try:
urlopen(gravatar_test_url, timeout=URL_TIMEOUT)
urlopen(gravatar_test_url)
except HTTPError as exc:
if exc.code == 404:
cache.set(gravatar_test_url, "default", 60)
else:
print("Gravatar test url fetch failed: %s" % exc)
print(f"Gravatar test url fetch failed: {exc}")
return redir_default(default)
gravatar_url = (
"https://secure.gravatar.com/avatar/" + kwargs["digest"] + "?s=%i" % size
)
if default:
gravatar_url += "&d=%s" % default
gravatar_url += f"&d={default}"
try:
if cache.get(gravatar_url) == "err":
print("Cached Gravatar fetch failed with URL error: %s" % gravatar_url)
print(f"Cached Gravatar fetch failed with URL error: {gravatar_url}")
return redir_default(default)
gravatarimagedata = urlopen(gravatar_url, timeout=URL_TIMEOUT)
gravatarimagedata = urlopen(gravatar_url)
except HTTPError as exc:
if exc.code != 404 and exc.code != 503:
if exc.code not in [404, 503]:
print(
"Gravatar fetch failed with an unexpected %s HTTP error: %s"
% (exc.code, gravatar_url)
f"Gravatar fetch failed with an unexpected {exc.code} HTTP error: {gravatar_url}"
)
cache.set(gravatar_url, "err", 30)
return redir_default(default)
except URLError as exc:
print("Gravatar fetch failed with URL error: %s" % exc.reason)
print(f"Gravatar fetch failed with URL error: {exc.reason}")
cache.set(gravatar_url, "err", 30)
return redir_default(default)
except SSLError as exc:
print("Gravatar fetch failed with SSL error: %s" % exc.reason)
print(f"Gravatar fetch failed with SSL error: {exc.reason}")
cache.set(gravatar_url, "err", 30)
return redir_default(default)
try:
@@ -427,13 +408,135 @@ class GravatarProxyView(View):
img = Image.open(data)
data.seek(0)
response = HttpResponse(
data.read(), content_type="image/%s" % file_format(img.format)
data.read(), content_type=f"image/{file_format(img.format)}"
)
response["Cache-Control"] = "max-age=%i" % CACHE_IMAGES_MAX_AGE
# Remove Vary header for images since language doesn't matter
response["Vary"] = ""
return response
except ValueError as exc:
print("Value error: %s" % exc)
print(f"Value error: {exc}")
return redir_default(default)
# We shouldn't reach this point... But make sure we do something
return redir_default(default)
class BlueskyProxyView(View):
"""
Proxy request to Bluesky and return the image from there
"""
def get(
self, request, *args, **kwargs
): # pylint: disable=too-many-branches,too-many-statements,too-many-locals,no-self-use,unused-argument,too-many-return-statements
"""
Override get from parent class
"""
def redir_default(default=None):
url = (
reverse_lazy("avatar_view", args=[kwargs["digest"]])
+ "?s=%i" % size
+ "&forcedefault=y"
)
if default is not None:
url += f"&default={default}"
return HttpResponseRedirect(url)
size = get_size(request)
print(size)
blueskyimagedata = None
default = None
with contextlib.suppress(Exception):
if str(request.GET["default"]) != "None":
default = request.GET["default"]
identity = None
# First check for email, as this is the most common
try:
identity = ConfirmedEmail.objects.filter(
Q(digest=kwargs["digest"]) | Q(digest_sha256=kwargs["digest"])
).first()
except Exception as exc:
print(exc)
# If no identity is found in the email table, try the openid table
if not identity:
try:
identity = ConfirmedOpenId.objects.filter(
Q(digest=kwargs["digest"])
| Q(alt_digest1=kwargs["digest"])
| Q(alt_digest2=kwargs["digest"])
| Q(alt_digest3=kwargs["digest"])
).first()
except Exception as exc:
print(exc)
# If still no identity is found, redirect to the default
if not identity:
return redir_default(default)
bs = Bluesky()
bluesky_url = None
# Try with the cache first
with contextlib.suppress(Exception):
if cache.get(identity.bluesky_handle):
bluesky_url = cache.get(identity.bluesky_handle)
if not bluesky_url:
try:
bluesky_url = bs.get_avatar(identity.bluesky_handle)
cache.set(identity.bluesky_handle, bluesky_url)
except Exception: # pylint: disable=bare-except
return redir_default(default)
try:
if cache.get(bluesky_url) == "err":
print(f"Cached Bluesky fetch failed with URL error: {bluesky_url}")
return redir_default(default)
blueskyimagedata = urlopen(bluesky_url)
except HTTPError as exc:
if exc.code not in [404, 503]:
print(
f"Bluesky fetch failed with an unexpected {exc.code} HTTP error: {bluesky_url}"
)
cache.set(bluesky_url, "err", 30)
return redir_default(default)
except URLError as exc:
print(f"Bluesky fetch failed with URL error: {exc.reason}")
cache.set(bluesky_url, "err", 30)
return redir_default(default)
except SSLError as exc:
print(f"Bluesky fetch failed with SSL error: {exc.reason}")
cache.set(bluesky_url, "err", 30)
return redir_default(default)
try:
data = BytesIO(blueskyimagedata.read())
img = Image.open(data)
img_format = img.format
if max(img.size) > size:
aspect = img.size[0] / float(img.size[1])
if aspect > 1:
new_size = (size, int(size / aspect))
else:
new_size = (int(size * aspect), size)
img = img.resize(new_size)
data = BytesIO()
img.save(data, format=img_format)
data.seek(0)
response = HttpResponse(
data.read(), content_type=f"image/{file_format(format)}"
)
response["Cache-Control"] = "max-age=%i" % CACHE_IMAGES_MAX_AGE
# Remove Vary header for images since language doesn't matter
response["Vary"] = ""
return response
except ValueError as exc:
print(f"Value error: {exc}")
return redir_default(default)
# We shouldn't reach this point... But make sure we do something

3
pyproject.toml Normal file
View File

@@ -0,0 +1,3 @@
[build-system]
requires = ['setuptools>=40.8.0', 'wheel']
build-backend = 'setuptools.build_meta:__legacy__'

View File

@@ -1,7 +1,8 @@
autopep8
bcrypt
celery
defusedxml
Django < 4.0
Django>=4.2.16
django-anymail[mailgun]
django-auth-ldap
django-bootstrap4
@@ -9,16 +10,18 @@ django-coverage-plugin
django-extensions
django-ipware
django-user-accounts
django_celery_results
dnspython==2.2.0
email-validator
fabric
flake8-respect-noqa
git+https://github.com/daboth/pagan.git
git+https://github.com/ercpe/pydenticon5.git
git+https://github.com/flavono123/identicon.git
git+https://github.com/necaris/python3-openid.git
git+https://github.com/ofalk/django-openid-auth
git+https://github.com/ofalk/monsterid.git
git+https://github.com/ofalk/Robohash.git@devel
mysqlclient
notsetuptools
Pillow
pip
@@ -27,11 +30,10 @@ py3dns
pydocstyle
pyLibravatar
pylint
pymemcache
PyMySQL
python-coveralls
python-language-server
python-memcached
python3-openid
pytz
rope
setuptools

33
setup.cfg Normal file
View File

@@ -0,0 +1,33 @@
[metadata]
name = libravatar
version = 1.7.0
description = A Django application implementing libravatar.org
long_description = file: README.md
url = https://libravatar.org
author = Oliver Falk
author_email = oliver@linux-kernel.at
license = GPLv3
classifiers =
Environment :: Web Environment
Framework :: Django
Framework :: Django :: 3.2
Framework :: Django :: 4.0
Framework :: Django :: 4.1
License :: OSI Approved :: GNU General Public License v3 (GPLv3)
Operating System :: OS Independent
Programming Language :: Python
Programming Language :: Python :: 3
Programming Language :: Python :: 3 :: Only
Programming Language :: Python :: 3.8
Programming Language :: Python :: 3.9
Programming Language :: Python :: 3.10
Programming Language :: Python :: 3.11
Topic :: Internet :: WWW/HTTP
Topic :: Internet :: WWW/HTTP :: Dynamic Content
[options]
include_package_data = true
packages = find:
python_requires = >=3.8
install_requires =
Django >= 3.2

6
setup.py Normal file
View File

@@ -0,0 +1,6 @@
# -*- coding: utf-8 -*-
from setuptools import setup, find_packages
setup(
packages=find_packages(),
)

View File

@@ -12,13 +12,15 @@
<ul class="dropdown-menu" aria-labelledby="navbarDropdownMenuLink">
{% if request.user.is_authenticated %}
<li><a href="{% url 'profile' %}"><i class="fa fa-fw fa-image" aria-hidden="true"></i> {% trans 'Profile' %}</a></li>
<li><a href="{% url 'generate_avatar' %}"><i class="fa fa-fw fa-magic" aria-hidden="true"></i> {% trans 'Generate AI Avatar' %}</a></li>
<li><a href="{% url 'avatar_gallery' %}"><i class="fa fa-fw fa-th" aria-hidden="true"></i> {% trans 'Avatar Gallery' %}</a></li>
<li><a href="{% url 'user_preference' %}"><i class="fa fa-fw fa-cog" aria-hidden="true"></i> {% trans 'Preferences' %}</a></li>
<li><a href="{% url 'import_photo' %}"><i class="fa fa-fw fa-envelope-square" aria-hidden="true"></i> {% trans 'Import photo via mail address' %}</a></li>
<li><a href="{% url 'upload_export' %}"><i class="fa fa-fw fa-file-archive-o" aria-hidden="true"></i> {% trans 'Import libravatar XML export' %}</a></li>
<li><a href="{% url 'export' %}"><i class="fa fa-fw fa-file-archive-o" aria-hidden="true"></i> {% trans 'Download your libravatar data' %}</a></li>
<li><a href="{% url 'password_change' %}"><i class="fa fa-fw fa-key" aria-hidden="true"></i> {% trans 'Change password' %}</a></li>
<li><a href="{% url 'password_reset' %}"><i class="fa fa-fw fa-unlock-alt" aria-hidden="true"></i> {% trans 'Reset password' %}</a></li>
<li><a href="{% url 'logout' %}"><i class="fa fa-fw fa-sign-out" aria-hidden="true"></i> {% trans 'Logout' %}</a></li>
<li>{% include '_account_logout.html' %}</li>
{% else %}
<li><a href="{% url 'login' %}"><i class="fa fa-fw fa-sign-in" aria-hidden="true"></i> {% trans 'Local' %}</a></li>
<li><a href="{% url 'new_account' %}"><i class="fa fa-fw fa-user-plus" aria-hidden="true"></i> {% trans 'Create account' %}</a></li>

View File

@@ -0,0 +1,6 @@
{% load i18n %}
<form id="logoutform" method="POST" action="{% url 'logout' %}">
{% csrf_token %}
<input type="hidden"/>
</form>
<a href="#" onClick="document.getElementById('logoutform').submit()"><i class="fa fa-fw fa-sign-out" aria-hidden="true"></i> {% trans 'Logout' %}</a>

View File

@@ -10,12 +10,17 @@
<div class="hero">
<div class="container">
{% if site_name == 'Libravatar DEVELOPMENT' %}
<div class="dev-indicator">DEVELOPMENT</div>
{% endif %}
<header>
<h1 id='app'>{{ site_name }}</h1>
<h1 id='app'>{% if site_name == 'Libravatar DEVELOPMENT' %}Libravatar{% else %}{{ site_name }}{% endif %}</h1>
<h2>{% trans 'freeing the web one face at a time' %}</h2>
{% if user.is_anonymous %}
<a href="/accounts/login/" class="btn btn-lg btn-primary">{% trans 'Login' %}</a>&nbsp;
<a href="/accounts/new/" class="btn btn-lg btn-primary">{% trans 'Sign up' %}</a>&nbsp;
<div class="btn-group">
<a href="/accounts/login/" class="btn btn-lg btn-primary">{% trans 'Login' %}</a>
<a href="/accounts/new/" class="btn btn-lg btn-primary">{% trans 'Sign up' %}</a>
</div>
{% else %}
<div class="btn-group">
<a class="btn btn-lg btn-primary dropdown-toggle" href="#" id="account_dropdown" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
@@ -23,21 +28,23 @@
</a>
<ul class="dropdown-menu" aria-labelledby="navbarDropdownMenuLink">
<li><a href="{% url 'profile' %}"><i class="fa fa-fw fa-image" aria-hidden="true"></i> {% trans 'Profile' %}</a></li>
<li><a href="{% url 'generate_avatar' %}"><i class="fa fa-fw fa-magic" aria-hidden="true"></i> {% trans 'Generate AI Avatar' %}</a></li>
<li><a href="{% url 'avatar_gallery' %}"><i class="fa fa-fw fa-th" aria-hidden="true"></i> {% trans 'Avatar Gallery' %}</a></li>
<li><a href="{% url 'user_preference' %}"><i class="fa fa-fw fa-cog" aria-hidden="true"></i> {% trans 'Preferences' %}</a></li>
<li><a href="{% url 'import_photo' %}"><i class="fa fa-fw fa-envelope-square" aria-hidden="true"></i> {% trans 'Import photo via mail address' %}</a></li>
<li><a href="{% url 'upload_export' %}"><i class="fa fa-fw fa-file-archive-o" aria-hidden="true"></i> {% trans 'Import libravatar XML export' %}</a></li>
<li><a href="{% url 'password_change' %}"><i class="fa fa-fw fa-key" aria-hidden="true"></i> {% trans 'Change password' %}</a></li>
<li><a href="{% url 'password_reset' %}"><i class="fa fa-fw fa-unlock-alt" aria-hidden="true"></i> {% trans 'Reset password' %}</a></li>
<li><a href="{% url 'logout' %}"><i class="fa fa-fw fa-sign-out" aria-hidden="true"></i> {% trans 'Logout' %}</a></li>
<li>{% include '_account_logout.html' %}</li>
{% if user.is_staff %}
<li>
<a href="{% url 'admin:index' %}" target="_new"><i class="fa fa-fw fa-user-secret" aria-hidden="true"></i> {% trans 'Admin' %}</a>
</li>
{% endif %}
</ul>
<a href="/tools/check/" class="btn btn-lg btn-primary">{% trans 'Check' %}</a>
</div>
{% endif %}
&nbsp;<a href="/tools/check/" class="btn btn-lg btn-primary">{% trans 'Check' %}</a>&nbsp;
</header>
</div>
</div>

View File

@@ -1,6 +1,6 @@
{% load i18n %}
{% block topbar_base %}
<nav class="navbar navbarlibravatar">
<nav class="navbar navbarlibravatar" {% if site_name == 'Libravatar DEVELOPMENT' %}style="background: linear-gradient(135deg, #aa0f1d 0%, #fd002e 100%);"{% endif %}>
<div class="container-fluid">
<div class="navbar-header">
<button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#navbar" aria-expanded="false">

View File

@@ -29,7 +29,7 @@
<p>
<button type="submit" class="button">{% trans 'Login' %}</button>
<input type="hidden" name="next" value="{{ request.build_absolute_uri }}{% url 'profile' %}" />
<input type="hidden" name="next" value="{% url 'profile' %}" />
&nbsp;
<button type="reset" class="button" onclick="window.history.back();">{% trans 'Cancel' %}</button>

View File

@@ -68,6 +68,12 @@ ivatar/Libravatar more secure by reporting security issues to us.
<li>
MR_NETWORK &amp; Farzan ʷᵒⁿᵈᵉʳ:
Spotted a problematic use of SECRET_KEY in the production environment. Many thanks for reporting it to us!</li>
<li>
<a href="https://x.com/capitan_alfa"
title="@capitan_alfa @ X" target="_new">
Ezequiel Fernandez</a>
Spotted public accessible secret keys in our test instance! We appreciate him notifying us privately about this issue!
</li>
</ul>