tum месяцев назад: 3
Родитель
Сommit
70dfc9cb35

+ 2 - 1
admin_frontend/templates/admin_frontend/base.html

@@ -27,6 +27,7 @@
27 27
           <div class="flex items-center gap-3">
28 28
             {% if request.user.is_authenticated %}
29 29
               <span class="text-sm text-gray-600">{{ request.user.username }}</span>
30
+              <a class="text-sm text-blue-600 hover:text-blue-700" href="{% url 'orgs_admin:my_profile' %}">{% trans "My Profile" %}</a>
30 31
               <form method="post" action="{% url 'admin_frontend:logout' %}">
31 32
                 {% csrf_token %}
32 33
                 <button class="text-sm text-red-600 hover:text-red-700">Logout</button>
@@ -58,7 +59,7 @@
58 59
         <div id="toasts" class="fixed bottom-4 right-4 z-50 space-y-2">
59 60
           {% for message in messages %}
60 61
             <div class="toast px-4 py-3 rounded border shadow {{ message.tags|default:'' }}">
61
-              {% trans message %}
62
+              {{ message }}
62 63
             </div>
63 64
           {% endfor %}
64 65
         </div>

+ 114 - 1
orgs/forms.py

@@ -1,7 +1,13 @@
1 1
 from __future__ import annotations
2 2
 
3 3
 from django import forms
4
-from .models import Organization
4
+from django.contrib.auth import get_user_model
5
+from django.core.exceptions import ValidationError
6
+from crispy_forms.helper import FormHelper
7
+from crispy_forms.layout import Layout, Field
8
+
9
+from .models import Organization, UserProfile
10
+from api.models import Profile as ApiProfile
5 11
 
6 12
 
7 13
 class OrganizationForm(forms.ModelForm):
@@ -11,3 +17,110 @@ class OrganizationForm(forms.ModelForm):
11 17
 
12 18
 
13 19
 ## ProvidedService moved to recycle_core app; form now lives in recycle_core.forms
20
+
21
+
22
+class UserSelfForm(forms.ModelForm):
23
+    class Meta:
24
+        model = get_user_model()
25
+        fields = ["first_name", "last_name", "email"]
26
+
27
+    def __init__(self, *args, **kwargs):
28
+        super().__init__(*args, **kwargs)
29
+        self.helper = FormHelper()
30
+        self.helper.form_tag = False
31
+        self.helper.layout = Layout(
32
+            Field("first_name"),
33
+            Field("last_name"),
34
+            Field("email"),
35
+        )
36
+
37
+
38
+class ProfileSelfForm(forms.ModelForm):
39
+    tags = forms.CharField(label="Tags", required=False, help_text="Comma-separated")
40
+
41
+    class Meta:
42
+        model = ApiProfile
43
+        fields = ["bio", "interests", "industry"]
44
+
45
+    def __init__(self, *args, **kwargs):
46
+        super().__init__(*args, **kwargs)
47
+        self.helper = FormHelper()
48
+        self.helper.form_tag = False
49
+        self.helper.layout = Layout(
50
+            Field("bio"),
51
+            Field("interests"),
52
+            Field("industry"),
53
+            Field("tags"),
54
+        )
55
+        if self.instance and getattr(self.instance, "pk", None):
56
+            try:
57
+                names = list(self.instance.tags.names())
58
+                self.fields["tags"].initial = ", ".join(names)
59
+            except Exception:
60
+                self.fields["tags"].initial = ""
61
+
62
+    def save(self, commit: bool = True):
63
+        profile = super().save(commit)
64
+        tags = [t.strip() for t in self.cleaned_data.get("tags", "").split(",") if t.strip()]
65
+        try:
66
+            if commit and hasattr(profile, "tags"):
67
+                profile.tags.set(tags)
68
+        except Exception:
69
+            pass
70
+        return profile
71
+
72
+
73
+class UserProfilePhotoForm(forms.ModelForm):
74
+    remove_photo = forms.BooleanField(label="Remove current photo", required=False)
75
+
76
+    class Meta:
77
+        model = UserProfile
78
+        fields = ["my_photo", "remove_photo"]
79
+
80
+    def __init__(self, *args, **kwargs):
81
+        super().__init__(*args, **kwargs)
82
+        # Friendlier widget: accept images only, keep input visually hidden (we trigger via button)
83
+        self.fields["my_photo"].widget = forms.ClearableFileInput(attrs={
84
+            "accept": "image/*",
85
+            "class": "hidden",
86
+            "id": "id_my_photo",
87
+        })
88
+        self.helper = FormHelper()
89
+        self.helper.form_tag = False
90
+        self.helper.layout = Layout(
91
+            Field("my_photo"),
92
+            Field("remove_photo"),
93
+        )
94
+
95
+    def clean_my_photo(self):
96
+        f = self.cleaned_data.get("my_photo")
97
+        if not f:
98
+            return f
99
+        # Basic size/type validation (max ~5MB)
100
+        try:
101
+            content_type = getattr(f, "content_type", "")
102
+            if content_type and not content_type.startswith("image/"):
103
+                raise ValidationError("Please upload an image file.")
104
+        except Exception:
105
+            # If storage/file doesn't provide content_type, best-effort skip
106
+            pass
107
+        max_bytes = 5 * 1024 * 1024
108
+        if getattr(f, "size", 0) and f.size > max_bytes:
109
+            raise ValidationError("Image too large (max 5MB).")
110
+        return f
111
+
112
+    def save(self, commit: bool = True):
113
+        instance: UserProfile = super().save(commit=False)
114
+        remove = self.cleaned_data.get("remove_photo", False)
115
+        new_file = self.cleaned_data.get("my_photo")
116
+        if remove and not new_file:
117
+            # Clear current photo
118
+            try:
119
+                if instance.my_photo:
120
+                    instance.my_photo.delete(save=False)
121
+            except Exception:
122
+                pass
123
+            instance.my_photo = None
124
+        if commit:
125
+            instance.save()
126
+        return instance

+ 18 - 0
orgs/migrations/0007_userprofile_my_photo.py

@@ -0,0 +1,18 @@
1
+# Generated by Django 4.2.24 on 2025-09-29 05:15
2
+
3
+from django.db import migrations, models
4
+
5
+
6
+class Migration(migrations.Migration):
7
+
8
+    dependencies = [
9
+        ('orgs', '0006_delete_providedservice'),
10
+    ]
11
+
12
+    operations = [
13
+        migrations.AddField(
14
+            model_name='userprofile',
15
+            name='my_photo',
16
+            field=models.ImageField(blank=True, null=True, upload_to='user_photos/'),
17
+        ),
18
+    ]

+ 1 - 0
orgs/models.py

@@ -69,6 +69,7 @@ class UserProfile(TimestampedModel):
69 69
     user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="recycle_profile")
70 70
     organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="users")
71 71
     role = models.CharField(max_length=16, choices=ROLE_CHOICES, default=ROLE_MANAGER)
72
+    my_photo = models.ImageField(upload_to="user_photos/", blank=True, null=True)
72 73
 
73 74
     def __str__(self) -> str:
74 75
         return f"{self.user.username} ({self.role})"

+ 82 - 0
orgs/templates/orgs/my_profile.html

@@ -0,0 +1,82 @@
1
+{% extends 'admin_frontend/base.html' %}
2
+{% load crispy_forms_tags %}
3
+{% block title %}My Profile{% endblock %}
4
+{% block content %}
5
+<div class="flex items-center justify-between mb-4">
6
+  <h1 class="text-xl font-semibold">My Profile</h1>
7
+  <a href="/webadmin/" class="btn-outline">Back</a>
8
+  <style>.btn-outline{border:1px solid #cbd5e1;padding:.375rem .5rem;border-radius:.375rem}</style>
9
+</div>
10
+
11
+<form method="post" enctype="multipart/form-data" class="bg-white rounded shadow p-4">
12
+  {% csrf_token %}
13
+  <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
14
+    <div class="md:col-span-2">
15
+      <h2 class="text-base font-semibold mb-2">Account</h2>
16
+      {{ form_user|crispy }}
17
+    </div>
18
+    {% if has_profile %}
19
+    <div class="md:col-span-2">
20
+      <h2 class="text-base font-semibold mb-2">Profile Photo</h2>
21
+      <div class="flex items-start gap-4">
22
+        <div>
23
+          <img id="my-photo-preview" src="{% if request.user.recycle_profile.my_photo %}{{ request.user.recycle_profile.my_photo.url }}{% else %}#{% endif %}" alt="Current photo" class="w-24 h-24 rounded object-cover border {% if not request.user.recycle_profile.my_photo %}hidden{% endif %}" />
24
+          {% if not request.user.recycle_profile.my_photo %}
25
+            <div id="my-photo-placeholder" class="w-24 h-24 rounded bg-gray-100 flex items-center justify-center text-gray-400 border">No photo</div>
26
+          {% endif %}
27
+        </div>
28
+        <div class="flex-1">
29
+          {{ form_photo.my_photo }}
30
+          <div class="flex items-center gap-3">
31
+            <button type="button" id="btn-pick-photo" class="btn-outline">Change photo</button>
32
+            {% if form_photo.remove_photo %}
33
+              <label class="inline-flex items-center gap-2 text-sm text-gray-700">
34
+                {{ form_photo.remove_photo }} Remove current photo
35
+              </label>
36
+            {% endif %}
37
+          </div>
38
+          <p class="text-xs text-gray-500 mt-1">JPG/PNG, up to 5MB.</p>
39
+          <style>.btn-outline{border:1px solid #cbd5e1;padding:.375rem .5rem;border-radius:.375rem}</style>
40
+        </div>
41
+      </div>
42
+    </div>
43
+    {% endif %}
44
+  </div>
45
+  <div class="mt-4">
46
+    <button class="btn-primary" type="submit">Save Changes</button>
47
+    <style>.btn-primary{background:#1d4ed8;color:#fff;padding:.5rem .75rem;border-radius:.375rem}</style>
48
+  </div>
49
+</form>
50
+<script>
51
+  (function(){
52
+    const input = document.getElementById('id_my_photo');
53
+    const btn = document.getElementById('btn-pick-photo');
54
+    const preview = document.getElementById('my-photo-preview');
55
+    const placeholder = document.getElementById('my-photo-placeholder');
56
+    const removeCb = document.getElementById('id_remove_photo');
57
+    if (btn && input) {
58
+      btn.addEventListener('click', function(){ input.click(); });
59
+    }
60
+    if (input) {
61
+      input.addEventListener('change', function(){
62
+        if (input.files && input.files[0]) {
63
+          const file = input.files[0];
64
+          const url = URL.createObjectURL(file);
65
+          if (preview) { preview.src = url; preview.classList.remove('hidden'); }
66
+          if (placeholder) { placeholder.classList.add('hidden'); }
67
+          if (removeCb) { removeCb.checked = false; }
68
+        }
69
+      });
70
+    }
71
+    if (removeCb) {
72
+      removeCb.addEventListener('change', function(){
73
+        if (removeCb.checked) {
74
+          if (preview) { preview.classList.add('hidden'); }
75
+          if (placeholder) { placeholder.classList.remove('hidden'); }
76
+          if (input) { input.value = ''; }
77
+        }
78
+      });
79
+    }
80
+  })();
81
+</script>
82
+{% endblock %}

+ 1 - 0
orgs/urls_admin.py

@@ -9,4 +9,5 @@ urlpatterns = [
9 9
     path("permissions/", views.permissions_overview, name="permissions_overview"),
10 10
     path("permissions/<int:pk>/", views.permissions_edit_group, name="permissions_edit_group"),
11 11
     path("permissions/<int:pk>/users/", views.permissions_group_users, name="permissions_group_users"),
12
+    path("me/", views.my_profile, name="my_profile"),
12 13
 ]

+ 37 - 1
orgs/views_admin.py

@@ -10,7 +10,7 @@ from django.core.paginator import Paginator, PageNotAnInteger, EmptyPage
10 10
 from django.db import models
11 11
 
12 12
 from .models import Organization, OrganizationSite, UserProfile
13
-from .forms import OrganizationForm
13
+from .forms import OrganizationForm, UserSelfForm, UserProfilePhotoForm
14 14
 
15 15
 from admin_frontend.templatetags.public_urls import public_route
16 16
 from cms.views import breadcrumbs
@@ -222,3 +222,39 @@ def permissions_group_users(request, pk: int):
222 222
         "orgs/permissions_group_users.html",
223 223
         {"group": group, "users": page_obj.object_list, "page_obj": page_obj, "q": q, "role": role, "role_choices": role_choices},
224 224
     )
225
+
226
+
227
+# ---------------------------------------------------------------------------
228
+# Self profile editing (current authenticated user)
229
+
230
+
231
+@breadcrumbs(label="My Profile", name="my_profile")
232
+def my_profile(request):
233
+    if not request.user.is_authenticated:
234
+        messages.error(request, "Please sign in to manage your profile.")
235
+        return redirect("admin_frontend:login")
236
+
237
+    user = request.user
238
+    user_profile = getattr(user, "recycle_profile", None)
239
+
240
+    if request.method == "POST":
241
+        form_user = UserSelfForm(request.POST, instance=user)
242
+        form_photo = UserProfilePhotoForm(request.POST, request.FILES, instance=user_profile) if user_profile else None
243
+        ok_user = form_user.is_valid()
244
+        ok_photo = True if form_photo is None else form_photo.is_valid()
245
+        if ok_user and ok_photo:
246
+            form_user.save()
247
+            if form_photo is not None:
248
+                form_photo.save()
249
+            messages.success(request, "Profile updated.")
250
+            return redirect(reverse("orgs_admin:my_profile"))
251
+        messages.error(request, "Please correct the errors below.")
252
+    else:
253
+        form_user = UserSelfForm(instance=user)
254
+        form_photo = UserProfilePhotoForm(instance=user_profile) if user_profile else None
255
+
256
+    return render(
257
+        request,
258
+        "orgs/my_profile.html",
259
+        {"form_user": form_user, "form_photo": form_photo, "has_profile": bool(user_profile)},
260
+    )

+ 143 - 0
public_frontend/forms.py

@@ -1,6 +1,12 @@
1 1
 from __future__ import annotations
2 2
 
3 3
 from django import forms
4
+from django.contrib.auth import get_user_model
5
+from django.core.exceptions import ValidationError
6
+from crispy_forms.helper import FormHelper
7
+from crispy_forms.layout import Layout, Field
8
+from django.contrib.auth import get_user_model
9
+from orgs.models import UserProfile
4 10
 
5 11
 
6 12
 class MultiFileInput(forms.ClearableFileInput):
@@ -41,3 +47,140 @@ class ContactForm(forms.Form):
41 47
     phone = forms.CharField(max_length=64, required=False)
42 48
     subject = forms.CharField(max_length=255, required=False)
43 49
     message = forms.CharField(widget=forms.Textarea)
50
+
51
+
52
+class RegistrationForm(forms.Form):
53
+    username = forms.CharField(max_length=150)
54
+    email = forms.EmailField()
55
+    password1 = forms.CharField(widget=forms.PasswordInput)
56
+    password2 = forms.CharField(widget=forms.PasswordInput)
57
+    role = forms.ChoiceField(choices=[(k, v) for k, v in UserProfile.ROLE_CHOICES if k != UserProfile.ROLE_OWNER])
58
+
59
+    def clean_username(self):
60
+        username = self.cleaned_data["username"].strip()
61
+        User = get_user_model()
62
+        if User.objects.filter(username__iexact=username).exists():
63
+            raise forms.ValidationError("Username already taken.")
64
+        return username
65
+
66
+    def clean_email(self):
67
+        email = self.cleaned_data["email"].strip()
68
+        User = get_user_model()
69
+        if User.objects.filter(email__iexact=email).exists():
70
+            raise forms.ValidationError("Email already registered.")
71
+        return email
72
+
73
+    def clean(self):
74
+        data = super().clean()
75
+        p1 = data.get("password1")
76
+        p2 = data.get("password2")
77
+        if p1 and p2 and p1 != p2:
78
+            self.add_error("password2", "Passwords do not match.")
79
+        return data
80
+
81
+
82
+class PublicUserForm(forms.ModelForm):
83
+    class Meta:
84
+        model = get_user_model()
85
+        fields = ["first_name", "last_name", "email"]
86
+
87
+    def __init__(self, *args, **kwargs):
88
+        super().__init__(*args, **kwargs)
89
+        self.helper = FormHelper()
90
+        self.helper.form_tag = False
91
+        self.helper.layout = Layout(
92
+            Field("first_name"),
93
+            Field("last_name"),
94
+            Field("email"),
95
+        )
96
+
97
+
98
+from api.models import Profile as ApiProfile
99
+from orgs.models import UserProfile
100
+
101
+
102
+class PublicProfileForm(forms.ModelForm):
103
+    tags = forms.CharField(label="Tags", required=False, help_text="Comma-separated")
104
+
105
+    class Meta:
106
+        model = ApiProfile
107
+        fields = ["bio", "interests", "industry"]
108
+
109
+    def __init__(self, *args, **kwargs):
110
+        super().__init__(*args, **kwargs)
111
+        self.helper = FormHelper()
112
+        self.helper.form_tag = False
113
+        self.helper.layout = Layout(
114
+            Field("bio"),
115
+            Field("interests"),
116
+            Field("industry"),
117
+            Field("tags"),
118
+        )
119
+        if self.instance and getattr(self.instance, "pk", None):
120
+            try:
121
+                names = list(self.instance.tags.names())
122
+                self.fields["tags"].initial = ", ".join(names)
123
+            except Exception:
124
+                self.fields["tags"].initial = ""
125
+
126
+    def save(self, commit: bool = True):
127
+        profile = super().save(commit)
128
+        tags = [t.strip() for t in self.cleaned_data.get("tags", "").split(",") if t.strip()]
129
+        try:
130
+            if commit and hasattr(profile, "tags"):
131
+                profile.tags.set(tags)
132
+        except Exception:
133
+            pass
134
+        return profile
135
+
136
+
137
+class PublicUserPhotoForm(forms.ModelForm):
138
+    remove_photo = forms.BooleanField(label="Remove current photo", required=False)
139
+
140
+    class Meta:
141
+        model = UserProfile
142
+        fields = ["my_photo", "remove_photo"]
143
+
144
+    def __init__(self, *args, **kwargs):
145
+        super().__init__(*args, **kwargs)
146
+        self.fields["my_photo"].widget = forms.ClearableFileInput(attrs={
147
+            "accept": "image/*",
148
+            "class": "hidden",
149
+            "id": "id_my_photo",
150
+        })
151
+        self.helper = FormHelper()
152
+        self.helper.form_tag = False
153
+        self.helper.layout = Layout(
154
+            Field("my_photo"),
155
+            Field("remove_photo"),
156
+        )
157
+
158
+    def clean_my_photo(self):
159
+        f = self.cleaned_data.get("my_photo")
160
+        if not f:
161
+            return f
162
+        try:
163
+            content_type = getattr(f, "content_type", "")
164
+            if content_type and not content_type.startswith("image/"):
165
+                raise ValidationError("Please upload an image file.")
166
+        except Exception:
167
+            pass
168
+        max_bytes = 5 * 1024 * 1024
169
+        if getattr(f, "size", 0) and f.size > max_bytes:
170
+            raise ValidationError("Image too large (max 5MB).")
171
+        return f
172
+
173
+    def save(self, commit: bool = True):
174
+        instance: UserProfile = super().save(commit=False)
175
+        remove = self.cleaned_data.get("remove_photo", False)
176
+        new_file = self.cleaned_data.get("my_photo")
177
+        if remove and not new_file:
178
+            try:
179
+                if instance.my_photo:
180
+                    instance.my_photo.delete(save=False)
181
+            except Exception:
182
+                pass
183
+            instance.my_photo = None
184
+        if commit:
185
+            instance.save()
186
+        return instance

+ 23 - 13
public_frontend/templates/public_frontend/base.html

@@ -20,19 +20,29 @@
20 20
         <a href="/#blog" class="hover:text-black/70">{% trans "Blog" %}</a>
21 21
         <a href="/#contact" class="hover:text-black/70">{% trans "Contact" %}</a>
22 22
       </nav>
23
-      <form method="post" action="{% url 'set_language' %}" class="hidden md:block ml-4">
24
-        {% csrf_token %}
25
-        <input type="hidden" name="next" value="{{ request.get_full_path }}" />
26
-        {% get_available_languages as langs %}
27
-        {% get_language_info_list for langs as languages %}
28
-        <label for="lang" class="sr-only">Language</label>
29
-        <select id="lang" name="language" class="text-sm border border-gray-300 rounded px-2 py-1 bg-white"
30
-                onchange="this.form.submit()">
31
-          {% for lang in languages %}
32
-            <option value="{{ lang.code }}" {% if lang.code == LANGUAGE_CODE %}selected{% endif %}>{{ lang.name_local }}</option>
33
-          {% endfor %}
34
-        </select>
35
-      </form>
23
+      <div class="hidden md:flex items-center gap-4 ml-4">
24
+        {% if request.user.is_authenticated %}
25
+          <span class="text-sm text-gray-600">{{ request.user.username }}</span>
26
+          <a class="text-sm text-blue-600 hover:text-blue-700" href="{% url 'public_frontend:my_profile' %}">{% trans "My Profile" %}</a>
27
+          <a class="text-sm text-blue-600 hover:text-blue-700" href="{% url 'public_frontend:logout' %}">{% trans "Logout" %}</a>
28
+        {% else %}
29
+          <a class="text-sm text-blue-600 hover:text-blue-700" href="{% url 'public_frontend:login' %}">{% trans "Login" %}</a>
30
+          <a class="text-sm text-blue-600 hover:text-blue-700" href="{% url 'public_frontend:register' %}">{% trans "Register" %}</a>
31
+        {% endif %}
32
+        <form method="post" action="{% url 'set_language' %}">
33
+          {% csrf_token %}
34
+          <input type="hidden" name="next" value="{{ request.get_full_path }}" />
35
+          {% get_available_languages as langs %}
36
+          {% get_language_info_list for langs as languages %}
37
+          <label for="lang" class="sr-only">Language</label>
38
+          <select id="lang" name="language" class="text-sm border border-gray-300 rounded px-2 py-1 bg-white"
39
+                  onchange="this.form.submit()">
40
+            {% for lang in languages %}
41
+              <option value="{{ lang.code }}" {% if lang.code == LANGUAGE_CODE %}selected{% endif %}>{{ lang.name_local }}</option>
42
+            {% endfor %}
43
+          </select>
44
+        </form>
45
+      </div>
36 46
     </div>
37 47
   </header>
38 48
   <main class="mx-auto max-w-7xl p-4 sm:p-6 lg:p-8 min-h-[80vh]">

+ 36 - 0
public_frontend/templates/public_frontend/login.html

@@ -0,0 +1,36 @@
1
+{% extends 'public_frontend/base.html' %}
2
+{% load i18n %}
3
+{% block title %}{% trans "Login" %}{% endblock %}
4
+
5
+{% block content %}
6
+<div class="max-w-md mx-auto">
7
+  <h1 class="text-2xl font-semibold mb-4">{% trans "Login" %}</h1>
8
+  <div class="bg-white rounded shadow p-4">
9
+    <form method="post" action="" class="grid gap-4">
10
+      {% csrf_token %}
11
+      {% if form.non_field_errors %}
12
+        <div class="text-sm text-rose-700">{{ form.non_field_errors }}</div>
13
+      {% endif %}
14
+      <div>
15
+        <label class="block text-sm font-medium mb-1" for="id_username">{% trans "Username" %}</label>
16
+        <input type="text" name="username" id="id_username" value="{{ form.username.value|default:'' }}" required class="w-full border rounded px-3 py-2">
17
+        {% if form.username.errors %}<div class="text-sm text-rose-700 mt-1">{{ form.username.errors|join:', ' }}</div>{% endif %}
18
+      </div>
19
+      <div>
20
+        <label class="block text-sm font-medium mb-1" for="id_password">{% trans "Password" %}</label>
21
+        <input type="password" name="password" id="id_password" required class="w-full border rounded px-3 py-2">
22
+        {% if form.password.errors %}<div class="text-sm text-rose-700 mt-1">{{ form.password.errors|join:', ' }}</div>{% endif %}
23
+      </div>
24
+      {% if next %}<input type="hidden" name="next" value="{{ next }}" />{% endif %}
25
+      <div>
26
+        <button class="btn-primary" type="submit">{% trans "Sign In" %}</button>
27
+      </div>
28
+    </form>
29
+    <p class="text-sm text-gray-600 mt-3">{% trans "Don't have an account?" %} <a href="{% url 'public_frontend:register' %}" class="text-blue-700 hover:underline">{% trans "Register" %}</a></p>
30
+  </div>
31
+</div>
32
+<style>
33
+  .btn-primary{background:#1d4ed8;color:#fff;padding:.5rem .75rem;border-radius:.375rem}
34
+</style>
35
+{% endblock %}
36
+

+ 89 - 0
public_frontend/templates/public_frontend/my_profile.html

@@ -0,0 +1,89 @@
1
+{% extends 'public_frontend/base.html' %}
2
+{% load crispy_forms_tags i18n %}
3
+{% block title %}{% trans "My Profile" %}{% endblock %}
4
+{% block content %}
5
+<div class="max-w-3xl mx-auto">
6
+  <h1 class="text-2xl font-semibold mb-4">{% trans "My Profile" %}</h1>
7
+  <form method="post" enctype="multipart/form-data" class="bg-white rounded shadow p-4">
8
+    {% csrf_token %}
9
+    <div class="grid grid-cols-1 gap-6">
10
+      <div>
11
+        <h2 class="text-base font-semibold mb-2">{% trans "Account" %}</h2>
12
+        <div class="grid md:grid-cols-2 gap-4 mb-2">
13
+          <div>
14
+            <label class="block text-sm font-medium mb-1">{% trans "Username" %}</label>
15
+            <input type="text" value="{{ username_value }}" class="w-full border rounded px-3 py-2 bg-gray-50" disabled>
16
+          </div>
17
+          <div>
18
+            <label class="block text-sm font-medium mb-1">{% trans "Role" %}</label>
19
+            <input type="text" value="{{ role_label }}" class="w-full border rounded px-3 py-2 bg-gray-50" disabled>
20
+          </div>
21
+        </div>
22
+        {{ form_user|crispy }}
23
+      </div>
24
+      {% if has_profile %}
25
+      <div>
26
+        <h2 class="text-base font-semibold mb-2">{% trans "Profile Photo" %}</h2>
27
+        <div class="flex items-start gap-4">
28
+          <div>
29
+            <img id="my-photo-preview" src="{% if request.user.recycle_profile.my_photo %}{{ request.user.recycle_profile.my_photo.url }}{% else %}#{% endif %}" alt="Current photo" class="w-24 h-24 rounded object-cover border {% if not request.user.recycle_profile.my_photo %}hidden{% endif %}" />
30
+            {% if not request.user.recycle_profile.my_photo %}
31
+              <div id="my-photo-placeholder" class="w-24 h-24 rounded bg-gray-100 flex items-center justify-center text-gray-400 border">No photo</div>
32
+            {% endif %}
33
+          </div>
34
+          <div class="flex-1">
35
+            {{ form_photo.my_photo }}
36
+            <div class="flex items-center gap-3">
37
+              <button type="button" id="btn-pick-photo" class="btn-outline">Change photo</button>
38
+              {% if form_photo.remove_photo %}
39
+                <label class="inline-flex items-center gap-2 text-sm text-gray-700">
40
+                  {{ form_photo.remove_photo }} Remove current photo
41
+                </label>
42
+              {% endif %}
43
+            </div>
44
+            <p class="text-xs text-gray-500 mt-1">JPG/PNG, up to 5MB.</p>
45
+            <style>.btn-outline{border:1px solid #cbd5e1;padding:.375rem .5rem;border-radius:.375rem}</style>
46
+          </div>
47
+        </div>
48
+      </div>
49
+      {% endif %}
50
+    </div>
51
+    <div class="mt-4">
52
+      <button class="btn-primary" type="submit">{% trans "Save Changes" %}</button>
53
+      <style>.btn-primary{background:#1d4ed8;color:#fff;padding:.5rem .75rem;border-radius:.375rem}</style>
54
+    </div>
55
+  </form>
56
+</div>
57
+<script>
58
+  (function(){
59
+    const input = document.getElementById('id_my_photo');
60
+    const btn = document.getElementById('btn-pick-photo');
61
+    const preview = document.getElementById('my-photo-preview');
62
+    const placeholder = document.getElementById('my-photo-placeholder');
63
+    const removeCb = document.getElementById('id_remove_photo');
64
+    if (btn && input) {
65
+      btn.addEventListener('click', function(){ input.click(); });
66
+    }
67
+    if (input) {
68
+      input.addEventListener('change', function(){
69
+        if (input.files && input.files[0]) {
70
+          const file = input.files[0];
71
+          const url = URL.createObjectURL(file);
72
+          if (preview) { preview.src = url; preview.classList.remove('hidden'); }
73
+          if (placeholder) { placeholder.classList.add('hidden'); }
74
+          if (removeCb) { removeCb.checked = false; }
75
+        }
76
+      });
77
+    }
78
+    if (removeCb) {
79
+      removeCb.addEventListener('change', function(){
80
+        if (removeCb.checked) {
81
+          if (preview) { preview.classList.add('hidden'); }
82
+          if (placeholder) { placeholder.classList.remove('hidden'); }
83
+          if (input) { input.value = ''; }
84
+        }
85
+      });
86
+    }
87
+  })();
88
+</script>
89
+{% endblock %}

+ 41 - 3
public_frontend/templates/public_frontend/pickup_request.html

@@ -4,7 +4,7 @@
4 4
 {% load crispy_forms_tags widget_tweaks %}
5 5
 {% block content %}
6 6
 <h1 class="text-xl font-semibold mb-4">{% trans "Request a Pickup" %}</h1>
7
-<div x-data="pickupForm()">
7
+<div x-data="pickupForm()" x-init="setupPreview()">
8 8
   <div class="bg-white rounded shadow p-4" >
9 9
     <form x-ref="form" id="pickup-form" method="post" enctype="multipart/form-data" class="space-y-4">
10 10
       {% csrf_token %}
@@ -24,7 +24,8 @@
24 24
         </div>
25 25
       </div>
26 26
       {{ form.materials|attr:"x-ref:materials"|as_crispy_field }}
27
-      {{ form.photos|attr:"x-ref:photos"|as_crispy_field }}
27
+      {{ form.photos|attr:"x-ref:photos"|attr:"accept:image/*"|as_crispy_field }}
28
+      <div x-ref="photos_preview" id="photos-preview" class="mt-2 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2"></div>
28 29
       <div>
29 30
         <button id="open-review" class="btn btn-primary" type="button" @click="openReview">Review &amp; Submit</button>
30 31
       </div>
@@ -67,6 +68,43 @@
67 68
   function pickupForm(){
68 69
     return {
69 70
       rv: {name:'', email:'', phone:'', address:'', preferred_at:'', materials:'', photos: []},
71
+      setupPreview(){
72
+        const input = this.$refs.photos;
73
+        const render = () => this.renderPreviews(input && input.files ? Array.from(input.files) : []);
74
+        if (input) {
75
+          input.addEventListener('change', render);
76
+          // Initial render (in case the browser retained selection)
77
+          render();
78
+        }
79
+      },
80
+      renderPreviews(files){
81
+        const wrap = this.$refs.photos_preview;
82
+        if (!wrap) return;
83
+        // Clear existing
84
+        while (wrap.firstChild) wrap.removeChild(wrap.firstChild);
85
+        if (!files || files.length === 0) return;
86
+        files.forEach(f => {
87
+          const item = document.createElement('div');
88
+          item.className = 'relative border rounded overflow-hidden bg-white shadow-sm';
89
+          if (f.type && f.type.startsWith('image/')) {
90
+            const img = document.createElement('img');
91
+            img.src = URL.createObjectURL(f);
92
+            img.alt = f.name;
93
+            img.className = 'w-full h-24 object-cover';
94
+            item.appendChild(img);
95
+          } else {
96
+            const box = document.createElement('div');
97
+            box.className = 'h-24 flex items-center justify-center text-xs text-gray-600 p-2 text-center';
98
+            box.textContent = f.name;
99
+            item.appendChild(box);
100
+          }
101
+          const cap = document.createElement('div');
102
+          cap.className = 'absolute bottom-0 left-0 right-0 bg-black/50 text-white text-[10px] px-1 py-0.5 truncate';
103
+          cap.textContent = f.name;
104
+          item.appendChild(cap);
105
+          wrap.appendChild(item);
106
+        });
107
+      },
70 108
       openReview(){
71 109
         console.log(this.$refs.form)
72 110
         const fd = new FormData(this.$refs.form);
@@ -86,7 +124,7 @@
86 124
         if(this.$refs.dialog.close){ this.$refs.dialog.close(); } else { this.$refs.dialog.removeAttribute('open'); }
87 125
         this.$refs.form.submit();
88 126
       }
89
-    }
90 127
   }
128
+}
91 129
 </script>
92 130
 {% endblock %}

+ 50 - 0
public_frontend/templates/public_frontend/register.html

@@ -0,0 +1,50 @@
1
+{% extends 'public_frontend/base.html' %}
2
+{% load i18n %}
3
+{% block title %}{% trans "Register" %}{% endblock %}
4
+{% block content %}
5
+<div class="max-w-xl mx-auto">
6
+  <h1 class="text-2xl font-semibold mb-4">{% trans "Create an Account" %}</h1>
7
+  <div class="bg-white rounded shadow p-4">
8
+    <form method="post" action="" class="grid gap-4">
9
+      {% csrf_token %}
10
+      <div>
11
+        <label class="block text-sm font-medium mb-1" for="id_username">{% trans "Username" %}</label>
12
+        <input type="text" name="username" id="id_username" value="{{ form.username.value|default:'' }}" required class="w-full border rounded px-3 py-2">
13
+        {% if form.username.errors %}<div class="text-sm text-rose-700 mt-1">{{ form.username.errors|join:", " }}</div>{% endif %}
14
+      </div>
15
+      <div>
16
+        <label class="block text-sm font-medium mb-1" for="id_email">{% trans "Email" %}</label>
17
+        <input type="email" name="email" id="id_email" value="{{ form.email.value|default:'' }}" required class="w-full border rounded px-3 py-2">
18
+        {% if form.email.errors %}<div class="text-sm text-rose-700 mt-1">{{ form.email.errors|join:", " }}</div>{% endif %}
19
+      </div>
20
+      <div class="grid md:grid-cols-2 gap-4">
21
+        <div>
22
+          <label class="block text-sm font-medium mb-1" for="id_password1">{% trans "Password" %}</label>
23
+          <input type="password" name="password1" id="id_password1" required class="w-full border rounded px-3 py-2">
24
+        </div>
25
+        <div>
26
+          <label class="block text-sm font-medium mb-1" for="id_password2">{% trans "Confirm Password" %}</label>
27
+          <input type="password" name="password2" id="id_password2" required class="w-full border rounded px-3 py-2">
28
+          {% if form.password2.errors %}<div class="text-sm text-rose-700 mt-1">{{ form.password2.errors|join:", " }}</div>{% endif %}
29
+        </div>
30
+      </div>
31
+      <div>
32
+        <label class="block text-sm font-medium mb-1" for="id_role">{% trans "Role" %}</label>
33
+        <select name="role" id="id_role" class="w-full border rounded px-3 py-2">
34
+          {% for val,label in form.fields.role.choices %}
35
+            <option value="{{ val }}" {% if form.role.value == val %}selected{% endif %}>{{ label }}</option>
36
+          {% endfor %}
37
+        </select>
38
+      </div>
39
+      <div>
40
+        <button class="btn-primary" type="submit">{% trans "Register" %}</button>
41
+      </div>
42
+    </form>
43
+    <p class="text-sm text-gray-600 mt-3">{% trans "Already have an account?" %} <a href="/accounts/login/" class="text-blue-700 hover:underline">{% trans "Login" %}</a></p>
44
+  </div>
45
+</div>
46
+<style>
47
+  .btn-primary{background:#1d4ed8;color:#fff;padding:.5rem .75rem;border-radius:.375rem}
48
+</style>
49
+{% endblock %}
50
+

+ 6 - 0
public_frontend/urls.py

@@ -1,10 +1,16 @@
1 1
 from django.urls import path
2 2
 from . import views
3
+from django.contrib.auth import views as auth_views
4
+from .views import PublicLoginView
3 5
 
4 6
 app_name = "public_frontend"
5 7
 
6 8
 urlpatterns = [
7 9
     path("", views.home, name="home"),
10
+    path("login/", PublicLoginView.as_view(), name="login"),
11
+    path("logout/", auth_views.LogoutView.as_view(next_page="public_frontend:home"), name="logout"),
12
+    path("me/", views.my_profile, name="my_profile"),
13
+    path("register/", views.register, name="register"),
8 14
     path("services/<int:pk>/", views.service_detail, name="service_detail"),
9 15
     path("materials/", views.materials_list, name="materials_list"),
10 16
     path("listings/", views.listings_list, name="listings_list"),

+ 83 - 2
public_frontend/views.py

@@ -5,18 +5,21 @@ from django.contrib import messages
5 5
 from django.urls import reverse
6 6
 from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
7 7
 
8
-from orgs.models import Organization
8
+from orgs.models import Organization, UserProfile
9 9
 from recycle_core.models import ProvidedService
10 10
 from recycle_core.models import Material, ScrapListing, PickupOrder
11 11
 from cms.models import Post, PostCategory
12 12
 
13
-from .forms import PickupRequestForm, ContactForm
13
+from .forms import PickupRequestForm, ContactForm, RegistrationForm, PublicUserForm, PublicUserPhotoForm
14 14
 from .models import Lead
15 15
 from recycle_core.controllers.pickup_request import (
16 16
     PickupRequestController,
17 17
     PickupRequestData,
18 18
 )
19 19
 
20
+from django.contrib.auth.views import LoginView
21
+from django.urls import reverse
22
+
20 23
 
21 24
 def home(request):
22 25
     org = getattr(request, "org", None)
@@ -42,6 +45,7 @@ def home(request):
42 45
 
43 46
     pickup_form = PickupRequestForm()
44 47
     contact_form = ContactForm()
48
+    register_form = RegistrationForm()
45 49
 
46 50
     return render(
47 51
         request,
@@ -54,6 +58,7 @@ def home(request):
54 58
             "pickup_form": pickup_form,
55 59
             "contact_form": contact_form,
56 60
             "org": org,
61
+            "register_form": register_form,
57 62
         },
58 63
     )
59 64
 
@@ -153,6 +158,41 @@ def contact(request):
153 158
     return render(request, "public_frontend/contact.html", {"form": form, "org": org})
154 159
 
155 160
 
161
+def register(request):
162
+    from django.contrib.auth import get_user_model
163
+    from django.contrib.auth.models import Group
164
+    from orgs.models import UserProfile
165
+
166
+    org = getattr(request, "org", None)
167
+    if not org:
168
+        messages.error(request, "Organization context missing.")
169
+        return redirect("public_frontend:home")
170
+
171
+    form = RegistrationForm(request.POST or None)
172
+    if request.method == "POST":
173
+        if form.is_valid():
174
+            User = get_user_model()
175
+            user = User(username=form.cleaned_data["username"], email=form.cleaned_data["email"], is_active=False)
176
+            user.set_password(form.cleaned_data["password1"])
177
+            user.save()
178
+            role = form.cleaned_data["role"]
179
+            # Create org profile
180
+            UserProfile.objects.create(user=user, organization=org, role=role)
181
+            # Add to group matching the role if exists
182
+            try:
183
+                g = Group.objects.filter(name=role).first()
184
+                if g:
185
+                    user.groups.add(g)
186
+            except Exception:
187
+                pass
188
+            messages.success(request, "Account created. Please wait for admin approval.")
189
+            return redirect("public_frontend:home")
190
+        else:
191
+            messages.error(request, "Please correct the errors below.")
192
+
193
+    return render(request, "public_frontend/register.html", {"form": form, "org": org})
194
+
195
+
156 196
 def blog_list(request):
157 197
     org = getattr(request, "org", None)
158 198
     qs = Post.objects.all().order_by("-published_at", "-created_at")
@@ -178,3 +218,44 @@ def blog_detail(request, slug: str):
178 218
     # Ensure markdown is rendered; fallback to raw content if needed
179 219
     html = getattr(post, "content_html", None) or getattr(post, "content", "")
180 220
     return render(request, "public_frontend/blog_detail.html", {"post": post, "html": html, "org": org})
221
+
222
+
223
+class PublicLoginView(LoginView):
224
+    template_name = "public_frontend/login.html"
225
+    redirect_authenticated_user = True
226
+
227
+    def get_success_url(self):
228
+        return reverse("public_frontend:home")
229
+
230
+
231
+from django.contrib.auth.decorators import login_required
232
+from django.contrib.auth import get_user_model
233
+
234
+
235
+@login_required
236
+def my_profile(request):
237
+    user = request.user
238
+    profile = getattr(user, "recycle_profile", None)
239
+    role_code = getattr(profile, "role", None)
240
+    role_label = dict(UserProfile.ROLE_CHOICES).get(role_code, "-") if role_code else "-"
241
+    if request.method == "POST":
242
+        form_user = PublicUserForm(request.POST, instance=user)
243
+        form_photo = PublicUserPhotoForm(request.POST, request.FILES, instance=profile) if profile else None
244
+        ok_user = form_user.is_valid()
245
+        ok_photo = True if form_photo is None else form_photo.is_valid()
246
+        if ok_user and ok_photo:
247
+            form_user.save()
248
+            if form_photo is not None:
249
+                form_photo.save()
250
+            messages.success(request, "Profile updated.")
251
+            return redirect(reverse("public_frontend:my_profile"))
252
+        messages.error(request, "Please correct the errors below.")
253
+    else:
254
+        form_user = PublicUserForm(instance=user)
255
+        form_photo = PublicUserPhotoForm(instance=profile) if profile else None
256
+
257
+    return render(
258
+        request,
259
+        "public_frontend/my_profile.html",
260
+        {"form_user": form_user, "form_photo": form_photo, "has_profile": bool(profile), "username_value": user.username, "role_label": role_label},
261
+    )

+ 44 - 0
recycle_core/forms.py

@@ -171,6 +171,7 @@ class UserEditForm(forms.Form):
171 171
     first_name = forms.CharField(max_length=150, required=False)
172 172
     last_name = forms.CharField(max_length=150, required=False)
173 173
     role = forms.ChoiceField(choices=UserProfile.ROLE_CHOICES)
174
+    is_active = forms.BooleanField(required=False, label="Active")
174 175
     password1 = forms.CharField(widget=forms.PasswordInput, required=False)
175 176
     password2 = forms.CharField(widget=forms.PasswordInput, required=False)
176 177
 
@@ -183,6 +184,49 @@ class UserEditForm(forms.Form):
183 184
         return cleaned
184 185
 
185 186
 
187
+class UserProfilePhotoForm(forms.ModelForm):
188
+    remove_photo = forms.BooleanField(label="Remove current photo", required=False)
189
+
190
+    class Meta:
191
+        model = UserProfile
192
+        fields = ["my_photo", "remove_photo"]
193
+
194
+    def __init__(self, *args, **kwargs):
195
+        super().__init__(*args, **kwargs)
196
+        self.fields["my_photo"].widget = forms.ClearableFileInput(attrs={
197
+            "accept": "image/*",
198
+            "class": "hidden",
199
+            "id": "id_my_photo",
200
+        })
201
+
202
+    def clean_my_photo(self):
203
+        f = self.cleaned_data.get("my_photo")
204
+        if not f:
205
+            return f
206
+        content_type = getattr(f, "content_type", "")
207
+        if content_type and not content_type.startswith("image/"):
208
+            raise ValidationError("Please upload an image file.")
209
+        max_bytes = 5 * 1024 * 1024
210
+        if getattr(f, "size", 0) and f.size > max_bytes:
211
+            raise ValidationError("Image too large (max 5MB).")
212
+        return f
213
+
214
+    def save(self, commit: bool = True):
215
+        instance: UserProfile = super().save(commit=False)
216
+        remove = self.cleaned_data.get("remove_photo", False)
217
+        new_file = self.cleaned_data.get("my_photo")
218
+        if remove and not new_file:
219
+            try:
220
+                if instance.my_photo:
221
+                    instance.my_photo.delete(save=False)
222
+            except Exception:
223
+                pass
224
+            instance.my_photo = None
225
+        if commit:
226
+            instance.save()
227
+        return instance
228
+
229
+
186 230
 # Weighing -------------------------------------------------------------------
187 231
 
188 232
 

+ 84 - 2
recycle_core/templates/recycle_core/user_form.html

@@ -8,8 +8,7 @@
8 8
   <a href="{{ cancel_url }}" class="btn-outline">Back</a>
9 9
   <style>.btn-outline{border:1px solid #cbd5e1;padding:0.375rem 0.5rem;border-radius:0.375rem}</style>
10 10
 </div>
11
-
12
-<form method="post" class="bg-white rounded shadow p-4 space-y-4">
11
+<form method="post" enctype="multipart/form-data" class="bg-white rounded shadow p-4 space-y-4">
13 12
   {% csrf_token %}
14 13
   <input type="hidden" name="next" value="{{ request.get_full_path }}">
15 14
   {% if creating %}
@@ -20,6 +19,18 @@
20 19
       <div>{% if form.last_name %}{{ form.last_name|as_crispy_field }}{% endif %}</div>
21 20
     </div>
22 21
     {% if form.role %}{{ form.role|as_crispy_field }}{% endif %}
22
+    {% if form.is_active %}
23
+      <div class="mt-2">
24
+        <div class="flex items-center gap-2">
25
+          {{ form.is_active }}
26
+          <label for="{{ form.is_active.id_for_label }}" class="text-sm font-medium">Active</label>
27
+        </div>
28
+        {% if form.is_active.errors %}
29
+          <div class="text-sm text-rose-700 mt-1">{{ form.is_active.errors|join:", " }}</div>
30
+        {% endif %}
31
+      </div>
32
+    {% endif %}
33
+    
23 34
     <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
24 35
       <div>{% if form.password1 %}{{ form.password1|as_crispy_field }}{% endif %}</div>
25 36
       <div>{% if form.password2 %}{{ form.password2|as_crispy_field }}{% endif %}</div>
@@ -35,6 +46,45 @@
35 46
       <div>{% if form.last_name %}{{ form.last_name|as_crispy_field }}{% endif %}</div>
36 47
     </div>
37 48
     {% if form.role %}{{ form.role|as_crispy_field }}{% endif %}
49
+    {% if form.is_active %}
50
+      <div class="mt-2">
51
+        <div class="flex items-center gap-2">
52
+          {{ form.is_active }}
53
+          <label for="{{ form.is_active.id_for_label }}" class="text-sm font-medium">Active</label>
54
+        </div>
55
+        {% if form.is_active.errors %}
56
+          <div class="text-sm text-rose-700 mt-1">{{ form.is_active.errors|join:", " }}</div>
57
+        {% endif %}
58
+      </div>
59
+    {% endif %}
60
+    {% if form_photo %}
61
+    <div class="mt-4">
62
+      <h3 class="text-sm font-semibold mb-2">Profile Photo</h3>
63
+      <div class="flex items-start gap-4">
64
+        <div>
65
+          {% with photo=item.recycle_profile.my_photo %}
66
+            <img id="my-photo-preview" src="{% if photo %}{{ photo.url }}{% else %}#{% endif %}" alt="Current photo" class="w-24 h-24 rounded object-cover border {% if not photo %}hidden{% endif %}" />
67
+            {% if not photo %}
68
+              <div id="my-photo-placeholder" class="w-24 h-24 rounded bg-gray-100 flex items-center justify-center text-gray-400 border">No photo</div>
69
+            {% endif %}
70
+          {% endwith %}
71
+        </div>
72
+        <div class="flex-1">
73
+          {{ form_photo.my_photo }}
74
+          <div class="flex items-center gap-3">
75
+            <button type="button" id="btn-pick-photo" class="btn-outline">Change photo</button>
76
+            {% if form_photo.remove_photo %}
77
+              <label class="inline-flex items-center gap-2 text-sm text-gray-700">
78
+                {{ form_photo.remove_photo }} Remove current photo
79
+              </label>
80
+            {% endif %}
81
+          </div>
82
+          <p class="text-xs text-gray-500 mt-1">JPG/PNG, up to 5MB.</p>
83
+          <style>.btn-outline{border:1px solid #cbd5e1;padding:.375rem .5rem;border-radius:.375rem}</style>
84
+        </div>
85
+      </div>
86
+    </div>
87
+    {% endif %}
38 88
     <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
39 89
       <div>{% if form.password1 %}{{ form.password1|as_crispy_field }}{% endif %}</div>
40 90
       <div>{% if form.password2 %}{{ form.password2|as_crispy_field }}{% endif %}</div>
@@ -46,4 +96,36 @@
46 96
     <style>.btn{background:#1d4ed8;color:#fff;padding:0.5rem 0.75rem;border-radius:0.375rem}</style>
47 97
   </div>
48 98
 </form>
99
+<script>
100
+  (function(){
101
+    const input = document.getElementById('id_my_photo');
102
+    const btn = document.getElementById('btn-pick-photo');
103
+    const preview = document.getElementById('my-photo-preview');
104
+    const placeholder = document.getElementById('my-photo-placeholder');
105
+    const removeCb = document.getElementById('id_remove_photo');
106
+    if (btn && input) {
107
+      btn.addEventListener('click', function(){ input.click(); });
108
+    }
109
+    if (input) {
110
+      input.addEventListener('change', function(){
111
+        if (input.files && input.files[0]) {
112
+          const file = input.files[0];
113
+          const url = URL.createObjectURL(file);
114
+          if (preview) { preview.src = url; preview.classList.remove('hidden'); }
115
+          if (placeholder) { placeholder.classList.add('hidden'); }
116
+          if (removeCb) { removeCb.checked = false; }
117
+        }
118
+      });
119
+    }
120
+    if (removeCb) {
121
+      removeCb.addEventListener('change', function(){
122
+        if (removeCb.checked) {
123
+          if (preview) { preview.classList.add('hidden'); }
124
+          if (placeholder) { placeholder.classList.remove('hidden'); }
125
+          if (input) { input.value = ''; }
126
+        }
127
+      });
128
+    }
129
+  })();
130
+</script>
49 131
 {% endblock %}

+ 17 - 3
recycle_core/views.py

@@ -153,7 +153,7 @@ def materials_list(request):
153 153
 
154 154
 
155 155
 # Organization Users (owner-only)
156
-from .forms import UserCreateForm, UserEditForm
156
+from .forms import UserCreateForm, UserEditForm, UserProfilePhotoForm
157 157
 
158 158
 
159 159
 @owner_required
@@ -223,12 +223,22 @@ def org_user_edit(request, pk: int):
223 223
         "first_name": user.first_name,
224 224
         "last_name": user.last_name,
225 225
         "role": getattr(profile, "role", UserProfile.ROLE_MANAGER),
226
+        "is_active": user.is_active,
226 227
     })
228
+    form_photo = UserProfilePhotoForm(request.POST or None, request.FILES or None, instance=profile) if profile else None
229
+    print(form_photo)
227 230
     if request.method == "POST":
228
-        if form.is_valid():
231
+        ok_main = form.is_valid()
232
+        ok_photo = True if form_photo is None else form_photo.is_valid()
233
+        if ok_main and ok_photo:
229 234
             user.email = form.cleaned_data.get("email") or ""
230 235
             user.first_name = form.cleaned_data.get("first_name") or ""
231 236
             user.last_name = form.cleaned_data.get("last_name") or ""
237
+            desired_active = bool(form.cleaned_data.get("is_active"))
238
+            if user.id == request.user.id and not desired_active:
239
+                messages.error(request, "You cannot deactivate your own account.")
240
+            else:
241
+                user.is_active = desired_active
232 242
             p1 = form.cleaned_data.get("password1")
233 243
             if p1:
234 244
                 user.set_password(p1)
@@ -239,13 +249,17 @@ def org_user_edit(request, pk: int):
239 249
             if org is not None:
240 250
                 profile.organization = org
241 251
             profile.save()
252
+            if form_photo is not None:
253
+                form_photo.instance = profile
254
+                form_photo.save()
242 255
             messages.success(request, f"User '{user.username}' updated.")
243 256
             next_url = request.POST.get("next") or request.GET.get("next")
244 257
             if next_url:
245 258
                 return redirect(next_url)
246 259
             return redirect("recycle_core:org_users_list")
247 260
         messages.error(request, "Please correct the errors below.")
248
-    return render(request, "recycle_core/user_form.html", {"nav": _nav_items(), "form": form, "item": user, "creating": False, "cancel_url": reverse("recycle_core:org_users_list")})
261
+    print(f"fp = {form_photo}")
262
+    return render(request, "recycle_core/user_form.html", {"nav": _nav_items(), "form": form, "form_photo": form_photo, "item": user, "creating": False, "cancel_url": reverse("recycle_core:org_users_list")})
249 263
 
250 264
 
251 265
 @owner_required