websockets + chat app (django)

This commit is contained in:
David Bruno Vontor
2025-10-31 13:32:39 +01:00
parent 8dd4f6e731
commit 4791bbc92c
22 changed files with 398 additions and 31 deletions

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.2.7 on 2025-10-31 07:36
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('account', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='customuser',
name='email_verification_sent_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='customuser',
name='email_verification_token',
field=models.CharField(blank=True, db_index=True, max_length=128, null=True),
),
]

View File

@@ -121,4 +121,32 @@ def send_email_test_task(email):
template_name="email/test.txt", template_name="email/test.txt",
html_template_name="email/test.html", html_template_name="email/test.html",
context=context, context=context,
)
@shared_task
def send_password_reset_email_task(user_id):
try:
user = CustomUser.objects.get(pk=user_id)
except CustomUser.DoesNotExist:
logger.info(f"Task send_password_reset_email has failed. Invalid User ID was sent.")
return 0
uid = urlsafe_base64_encode(force_bytes(user.pk))
token = password_reset_token.make_token(user)
reset_url = f"{settings.FRONTEND_URL}/reset-password/{uid}/{token}"
context = {
"user": _build_user_template_ctx(user),
"action_url": reset_url,
"frontend_url": settings.FRONTEND_URL,
"cta_label": "Obnovit heslo",
}
send_email_with_context(
recipients=user.email,
subject="Obnova hesla",
template_name="email/password_reset.txt",
html_template_name="email/password_reset.html",
context=context,
) )

View File

@@ -1,3 +1,28 @@
from django.test import TestCase from django.test import TestCase
from django.contrib.auth import get_user_model
from rest_framework.test import APIClient
# Create your tests here.
class UserViewAnonymousTests(TestCase):
def setUp(self):
self.client = APIClient()
User = get_user_model()
self.target_user = User.objects.create_user(
username="target",
email="target@example.com",
password="pass1234",
is_active=True,
)
def test_anonymous_update_user_is_forbidden_and_does_not_crash(self):
url = f"/api/account/users/{self.target_user.id}/"
payload = {"username": "newname", "email": self.target_user.email}
resp = self.client.put(url, data=payload, format="json")
# Expect 403 Forbidden (permission denied), but most importantly no 500 error
self.assertEqual(resp.status_code, 403, msg=f"Unexpected status: {resp.status_code}, body={getattr(resp, 'data', resp.content)}")
def test_anonymous_retrieve_user_is_unauthorized(self):
url = f"/api/account/users/{self.target_user.id}/"
resp = self.client.get(url)
# Retrieve requires authentication per view; expect 401 Unauthorized
self.assertEqual(resp.status_code, 401, msg=f"Unexpected status: {resp.status_code}, body={getattr(resp, 'data', resp.content)}")

View File

@@ -229,13 +229,17 @@ class UserView(viewsets.ModelViewSet):
# Only admin or the user themselves can update or delete # Only admin or the user themselves can update or delete
elif self.action in ['update', 'partial_update', 'destroy']: elif self.action in ['update', 'partial_update', 'destroy']:
if self.request.user.role == 'admin': user = getattr(self, 'request', None) and getattr(self.request, 'user', None)
# Admins can modify any user
if user and getattr(user, 'is_authenticated', False) and getattr(user, 'role', None) == 'admin':
return [OnlyRolesAllowed("admin")()] return [OnlyRolesAllowed("admin")()]
elif self.kwargs.get('pk') and str(self.request.user.id) == self.kwargs['pk']:
# Users can modify their own record
if user and getattr(user, 'is_authenticated', False) and self.kwargs.get('pk') and str(getattr(user, 'id', '')) == self.kwargs['pk']:
return [IsAuthenticated()] return [IsAuthenticated()]
else:
# fallback - deny access # Fallback - deny access (prevents AttributeError for AnonymousUser)
return [OnlyRolesAllowed("admin")()] return [OnlyRolesAllowed("admin")()]
# Any authenticated user can retrieve (view) any user's profile # Any authenticated user can retrieve (view) any user's profile
elif self.action == 'retrieve': elif self.action == 'retrieve':

View File

View File

@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

View File

@@ -0,0 +1,8 @@
from django.apps import AppConfig
class ChatConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'social.chat'
label = "chat"

View File

@@ -0,0 +1,27 @@
# chat/consumers.py
import json
from account.models import UserProfile
from channels.db import database_sync_to_async
from channels.generic.websocket import AsyncWebsocketConsumer
class ChatConsumer(AsyncWebsocketConsumer):
async def connect(self):
await self.accept()
async def disconnect(self, close_code):
pass
async def receive(self, text_data):
text_data_json = json.loads(text_data)
message = text_data_json["message"]
await self.send(text_data=json.dumps({"message": message}))
@database_sync_to_async
def get_user_profile(user_id):
return UserProfile.objects.get(pk=user_id)

View File

@@ -0,0 +1,3 @@
from django.db import models
# Create your models here.

View File

@@ -0,0 +1,8 @@
# chat/routing.py
from django.urls import re_path
from . import consumers
websocket_urlpatterns = [
re_path(r"ws/chat/(?P<room_name>\w+)/$", consumers.ChatConsumer.as_asgi()),
]

View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View File

@@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.

View File

@@ -326,6 +326,8 @@ REST_FRAMEWORK = {
MY_CREATED_APPS = [ MY_CREATED_APPS = [
'account', 'account',
'commerce', 'commerce',
'social.chat',
'thirdparty.downloader', 'thirdparty.downloader',
'thirdparty.stripe', # register Stripe app so its models are recognized 'thirdparty.stripe', # register Stripe app so its models are recognized

View File

@@ -3,17 +3,31 @@ import Home from "./pages/home/home";
import HomeLayout from "./layouts/HomeLayout"; import HomeLayout from "./layouts/HomeLayout";
import Downloader from "./pages/downloader/Downloader"; import Downloader from "./pages/downloader/Downloader";
import PrivateRoute from "./routes/PrivateRoute";
import { UserContextProvider } from "./context/UserContext";
export default function App() { export default function App() {
return ( return (
<Router> <Router>
<Routes> <UserContextProvider>
{/* Layout route */} {/* Layout route */}
<Route path="/" element={<HomeLayout />}> <Route path="/" element={<HomeLayout />}>
<Route index element={<Home />} /> <Route index element={<Home />} />
<Route path="downloader" element={<Downloader />} /> <Route path="downloader" element={<Downloader />} />
</Route> </Route>
</Routes>
<Route element={<PrivateRoute />}>
{/* Protected routes go here */}
<Route path="/" element={<HomeLayout />} >
<Route path="protected-downloader" element={<Downloader />} />
</Route>
</Route>
</UserContextProvider>
</Router> </Router>
) )
} }

View File

@@ -3,8 +3,7 @@ import axios from "axios";
// --- ENV CONFIG --- // --- ENV CONFIG ---
const API_BASE_URL = const API_BASE_URL =
import.meta.env.VITE_API_BASE_URL || "http://localhost:8000"; import.meta.env.VITE_API_BASE_URL || "http://localhost:8000";
const REFRESH_URL =
import.meta.env.VITE_API_REFRESH_URL || "/api/token/refresh/";
const LOGIN_PATH = import.meta.env.VITE_LOGIN_PATH || "/login"; const LOGIN_PATH = import.meta.env.VITE_LOGIN_PATH || "/login";
@@ -30,6 +29,7 @@ function notifyError(detail: ApiErrorDetail) {
function onError(handler: ApiErrorHandler) { function onError(handler: ApiErrorHandler) {
const wrapped = handler as EventListener; const wrapped = handler as EventListener;
window.addEventListener(ERROR_EVENT, wrapped as EventListener); window.addEventListener(ERROR_EVENT, wrapped as EventListener);
return () => window.removeEventListener(ERROR_EVENT, wrapped); return () => window.removeEventListener(ERROR_EVENT, wrapped);
} }
@@ -38,7 +38,7 @@ function onError(handler: ApiErrorHandler) {
function createAxios(baseURL: string): any { function createAxios(baseURL: string): any {
const instance = axios.create({ const instance = axios.create({
baseURL, baseURL,
withCredentials: true, // <-- always true withCredentials: true, // cookies
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
@@ -51,12 +51,14 @@ function createAxios(baseURL: string): any {
const apiPublic = createAxios(API_BASE_URL); const apiPublic = createAxios(API_BASE_URL);
const apiAuth = createAxios(API_BASE_URL); const apiAuth = createAxios(API_BASE_URL);
// --- REQUEST INTERCEPTOR (PUBLIC) --- // --- REQUEST INTERCEPTOR (PUBLIC) ---
// Ensure no Authorization header is ever sent by the public client // Ensure no Authorization header is ever sent by the public client
apiPublic.interceptors.request.use(function (config: any) { apiPublic.interceptors.request.use(function (config: any) {
if (config?.headers && (config.headers as any).Authorization) { if (config?.headers && (config.headers as any).Authorization) {
delete (config.headers as any).Authorization; delete (config.headers as any).Authorization;
} }
return config; return config;
}); });
@@ -64,6 +66,7 @@ apiPublic.interceptors.request.use(function (config: any) {
// Do not attach Authorization header; rely on cookies set by Django. // Do not attach Authorization header; rely on cookies set by Django.
apiAuth.interceptors.request.use(function (config: any) { apiAuth.interceptors.request.use(function (config: any) {
(config as any)._retryCount = (config as any)._retryCount || 0; (config as any)._retryCount = (config as any)._retryCount || 0;
return config; return config;
}); });
@@ -76,16 +79,18 @@ apiAuth.interceptors.response.use(
async function (error: any) { async function (error: any) {
if (!error.response) { if (!error.response) {
alert("Backend connection is unavailable. Please check your network."); alert("Backend connection is unavailable. Please check your network.");
notifyError({ notifyError({
message: "Network error or backend unavailable", message: "Network error or backend unavailable",
url: error.config?.url, url: error.config?.url,
}); });
return Promise.reject(error); return Promise.reject(error);
} }
const status = error.response.status; const status = error.response.status;
if (status === 401) { if (status === 401) {
clearTokens(); // optional: clear cookies client-side ClearTokens();
window.location.assign(LOGIN_PATH); window.location.assign(LOGIN_PATH);
return Promise.reject(error); return Promise.reject(error);
} }
@@ -130,35 +135,37 @@ apiPublic.interceptors.response.use(
} }
); );
// --- TOKEN HELPERS (NO-OPS) ---
// Django sets/rotates cookies server-side. Keep API surface to avoid breaking imports.
function setTokens(_access?: string, _refresh?: string) {
// no-op: cookies are managed by Django function Logout() {
}
function clearTokens() {
// optional: try to clear auth cookies client-side; server should also clear on logout
try { try {
document.cookie = "access_token=; Max-Age=0; path=/"; const LogOutResponse = apiAuth.post("/api/logout/");
document.cookie = "refresh_token=; Max-Age=0; path=/";
} catch { if (LogOutResponse.body.detail != "Logout successful") {
// ignore throw new Error("Logout failed");
}
ClearTokens();
} catch (error) {
console.error("Error during logout:", error);
} }
} }
function getAccessToken(): string | null {
// no Authorization header is used; rely purely on cookies function ClearTokens(){
return null; document.cookie = "access_token=; Max-Age=0; path=/";
document.cookie = "refresh_token=; Max-Age=0; path=/";
} }
// --- EXPORT DEFAULT API WRAPPER --- // --- EXPORT DEFAULT API WRAPPER ---
const Client = { const Client = {
// Axios instances // Axios instances
auth: apiAuth, auth: apiAuth,
public: apiPublic, public: apiPublic,
// Token helpers (kept for compatibility; now no-ops) Logout,
setTokens,
clearTokens,
getAccessToken,
// Error subscription // Error subscription
onError, onError,

View File

@@ -0,0 +1,82 @@
// frontend/src/api/model/user.js
// User API model for searching users by username
// Structure matches other model files (see order.js for reference)
import Client from '../Client';
const API_BASE_URL = "/account/users";
const userAPI = {
/**
* Get current authenticated user
* @returns {Promise<User>}
*/
async getCurrentUser() {
const response = await Client.auth.get(`${API_BASE_URL}/me/`);
return response.data;
},
/**
* Get all users
* @returns {Promise<Array<User>>}
*/
async getUsers(params: Object) {
const response = await Client.auth.get(`${API_BASE_URL}/`, { params });
return response.data;
},
/**
* Get a single user by ID
* @param {number|string} id
* @returns {Promise<User>}
*/
async getUser(id: number) {
const response = await Client.auth.get(`${API_BASE_URL}/${id}/`);
return response.data;
},
/**
* Update a user by ID
* @param {number|string} id
* @param {Object} data
* @returns {Promise<User>}
*/
async updateUser(id: number, data: Object) {
const response = await Client.auth.patch(`${API_BASE_URL}/${id}/`, data);
return response.data;
},
/**
* Delete a user by ID
* @param {number|string} id
* @returns {Promise<void>}
*/
async deleteUser(id: number) {
const response = await Client.auth.delete(`${API_BASE_URL}/${id}/`);
return response.data;
},
/**
* Create a new user
* @param {Object} data
* @returns {Promise<User>}
*/
async createUser(data: Object) {
const response = await Client.auth.post(`${API_BASE_URL}/`, data);
return response.data;
},
/**
* Search users by username (partial match)
* @param {Object} params - { username: string }
* @returns {Promise<Array<User>>}
*/
async searchUsers(params: { username: string }) {
// Adjust the endpoint as needed for your backend
const response = await Client.auth.get(`${API_BASE_URL}/`, { params });
console.log("User search response:", response.data);
return response.data;
},
};
export default userAPI;

View File

@@ -0,0 +1,19 @@
const wsUri = "ws://127.0.0.1/";
const websocket = new WebSocket(wsUri);
websocket.onopen = function (event) {
console.log("WebSocket is open now.", event);
};
websocket.onmessage = function (event) {
console.log("WebSocket message received:", event.data);
};
websocket.onclose = function (event) {
console.log("WebSocket is closed now.", event.reason);
};
websocket.onerror = function (event) {
console.error("WebSocket error observed:", event);
};

View File

@@ -1,12 +1,16 @@
import React, { useState } from "react" import React, { useState, useContext } from "react"
import styles from "./HomeNav.module.css" import styles from "./HomeNav.module.css"
import { FaBars, FaChevronDown } from "react-icons/fa"; import { FaBars, FaChevronDown } from "react-icons/fa";
import { UserContext } from "../../context/UserContext";
export default function HomeNav() { export default function HomeNav() {
const [navOpen, setNavOpen] = useState(false) const [navOpen, setNavOpen] = useState(false)
const toggleNav = () => setNavOpen((prev) => !prev) const toggleNav = () => setNavOpen((prev) => !prev)
const { user } = useContext(UserContext);
return ( return (
<nav className={styles.nav}> <nav className={styles.nav}>
<FaBars <FaBars

View File

@@ -0,0 +1,30 @@
/*
# EXAMPLE USAGE OF CONTEXT IN A COMPONENT:
## Wrap your app tree with the provider (e.g., in App.tsx)
```tsx
import { UserContextProvider } from "../context/UserContext";
function App() {
return (
<UserContextProvider>
<YourRoutes />
</UserContextProvider>
);
}
```
## Consume in any child component
```tsx
import React, { useContext } from "react"
import { UserContext } from '../context/UserContext';
export default function ExampleComponent() {
const { user, setUser } = useContext(UserContext);
return ...;
}
```

View File

View File

@@ -0,0 +1,74 @@
import React, { createContext, useState, useContext, useEffect } from 'react';
import userAPI from '../api/models/User';
// definice uživatele
export interface User {
id: string;
email: string;
username: string;
}
// určíme typ kontextu
interface GlobalContextType {
user: User | null;
setUser: React.Dispatch<React.SetStateAction<User | null>>;
}
// vytvoříme a exportneme kontext
export const UserContext = createContext<GlobalContextType | null>(null);
// hook pro použití kontextu
export const UserContextProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
const fetchUser = async () => {
try {
const currentUser = await userAPI.getCurrentUser();
setUser(currentUser);
} catch (error) {
console.error('Failed to load user:', error);
setUser(null);
}
};
fetchUser();
}, []);
return (
<UserContext.Provider value={{ user, setUser }}>
{children}
</UserContext.Provider>
);
};
/*
EXAMPLE USAGE OF CONTEXT IN A COMPONENT:
// Wrap your app tree with the provider (e.g., in App.tsx)
// import { UserContextProvider } from "../context/UserContext";
// function App() {
// return (
// <UserContextProvider>
// <YourRoutes />
// </UserContextProvider>
// );
// }
// Consume in any child component
import React, { useContext } from "react"
import { UserContext } from '../context/UserContext';
export default function ExampleComponent() {
const { user, setUser } = useContext(UserContext);
return ...;
}
*/