Added dynamic QR code generation for job-analysis
This commit is contained in:
parent
cf5936730c
commit
b130cd3974
@ -294,17 +294,16 @@ RUN apt-get update \
|
|||||||
WORKDIR /opt/ollama
|
WORKDIR /opt/ollama
|
||||||
|
|
||||||
# Download the nightly ollama release from ipex-llm
|
# Download the nightly ollama release from ipex-llm
|
||||||
#ENV OLLAMA_VERSION=https://github.com/intel/ipex-llm/releases/download/v2.2.0/ollama-ipex-llm-2.2.0-ubuntu.tgz
|
|
||||||
#ENV OLLAMA_VERSION=https://github.com/intel/ipex-llm/releases/download/v2.3.0-nightly/ollama-ipex-llm-2.3.0b20250415-ubuntu.tgz
|
|
||||||
|
|
||||||
# NOTE: NO longer at github.com/intel -- now at ipex-llm
|
# NOTE: NO longer at github.com/intel -- now at ipex-llm
|
||||||
|
|
||||||
# This version does not work:
|
# This version does not work:
|
||||||
# ENV OLLAMA_VERSION=https://github.com/ipex-llm/ipex-llm/releases/download/v2.3.0-nightly/ollama-ipex-llm-2.3.0b20250429-ubuntu.tgz
|
|
||||||
|
|
||||||
ENV OLLAMA_VERSION=https://github.com/ipex-llm/ipex-llm/releases/download/v2.2.0/ollama-ipex-llm-2.2.0-ubuntu.tgz
|
ENV OLLAMA_VERSION=https://github.com/ipex-llm/ipex-llm/releases/download/v2.2.0/ollama-ipex-llm-2.2.0-ubuntu.tgz
|
||||||
|
|
||||||
#ENV OLLAMA_VERSION=https://github.com/ipex-llm/ipex-llm/releases/download/v2.3.0-nightly/ollama-ipex-llm-2.3.0b20250429-ubuntu.tgz
|
|
||||||
|
# Does not work -- crashes
|
||||||
|
# ENV OLLAMA_VERSION=https://github.com/ipex-llm/ipex-llm/releases/download/v2.3.0-nightly/ollama-ipex-llm-2.3.0b20250612-ubuntu.tgz
|
||||||
|
|
||||||
RUN wget -qO - ${OLLAMA_VERSION} | \
|
RUN wget -qO - ${OLLAMA_VERSION} | \
|
||||||
tar --strip-components=1 -C . -xzv
|
tar --strip-components=1 -C . -xzv
|
||||||
|
|
||||||
|
@ -4,8 +4,8 @@ services:
|
|||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
target: backstory
|
target: backstory
|
||||||
container_name: backstory
|
|
||||||
#image: backstory
|
#image: backstory
|
||||||
|
container_name: backstory
|
||||||
restart: "always"
|
restart: "always"
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
@ -15,6 +15,7 @@ services:
|
|||||||
- MODEL_NAME=${MODEL_NAME:-qwen2.5:7b}
|
- MODEL_NAME=${MODEL_NAME:-qwen2.5:7b}
|
||||||
- REDIS_URL=redis://redis:6379
|
- REDIS_URL=redis://redis:6379
|
||||||
- REDIS_DB=0
|
- REDIS_DB=0
|
||||||
|
- SSL_ENABLED=true
|
||||||
devices:
|
devices:
|
||||||
- /dev/dri:/dev/dri
|
- /dev/dri:/dev/dri
|
||||||
depends_on:
|
depends_on:
|
||||||
@ -50,6 +51,7 @@ services:
|
|||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
- PRODUCTION=1
|
- PRODUCTION=1
|
||||||
|
- FRONTEND_URL=https://backstory.ketrenos.com
|
||||||
- MODEL_NAME=${MODEL_NAME:-qwen2.5:7b}
|
- MODEL_NAME=${MODEL_NAME:-qwen2.5:7b}
|
||||||
- REDIS_URL=redis://redis:6379
|
- REDIS_URL=redis://redis:6379
|
||||||
- REDIS_DB=1
|
- REDIS_DB=1
|
||||||
@ -58,6 +60,7 @@ services:
|
|||||||
- /dev/dri:/dev/dri
|
- /dev/dri:/dev/dri
|
||||||
depends_on:
|
depends_on:
|
||||||
- ollama
|
- ollama
|
||||||
|
- redis
|
||||||
networks:
|
networks:
|
||||||
- internal
|
- internal
|
||||||
ports:
|
ports:
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"short_name": "ai.ketrenos.com",
|
"short_name": "backstory.ketrenos.com",
|
||||||
"name": "Ketrenos AI Chat",
|
"name": "Backstory",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "favicon.ico",
|
"src": "favicon.ico",
|
||||||
|
@ -55,7 +55,7 @@ import { parsePhoneNumberFromString } from 'libphonenumber-js';
|
|||||||
import { useReactToPrint } from 'react-to-print';
|
import { useReactToPrint } from 'react-to-print';
|
||||||
|
|
||||||
import { useAuth } from 'hooks/AuthContext';
|
import { useAuth } from 'hooks/AuthContext';
|
||||||
import { useAppState } from 'hooks/GlobalContext';
|
import { useAppState, useSelectedJob } from 'hooks/GlobalContext';
|
||||||
import { StyledMarkdown } from 'components/StyledMarkdown';
|
import { StyledMarkdown } from 'components/StyledMarkdown';
|
||||||
import { Resume } from 'types/types';
|
import { Resume } from 'types/types';
|
||||||
import { BackstoryTextField } from 'components/BackstoryTextField';
|
import { BackstoryTextField } from 'components/BackstoryTextField';
|
||||||
@ -355,10 +355,11 @@ const resumeStyles: Record<string, ResumeStyle> = {
|
|||||||
// Styled Header Component
|
// Styled Header Component
|
||||||
interface BackstoryStyledResumeProps {
|
interface BackstoryStyledResumeProps {
|
||||||
candidate: Types.Candidate;
|
candidate: Types.Candidate;
|
||||||
|
job?: Types.Job;
|
||||||
style: ResumeStyle;
|
style: ResumeStyle;
|
||||||
}
|
}
|
||||||
|
|
||||||
const StyledFooter: React.FC<BackstoryStyledResumeProps> = ({ candidate, style }) => {
|
const StyledFooter: React.FC<BackstoryStyledResumeProps> = ({ candidate, job, style }) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Box
|
<Box
|
||||||
@ -378,16 +379,16 @@ const StyledFooter: React.FC<BackstoryStyledResumeProps> = ({ candidate, style }
|
|||||||
color: style.color.secondary,
|
color: style.color.secondary,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Ask any questions you may have at my Backstory...
|
Dive deeper into my qualifications at Backstory...
|
||||||
<Box
|
<Box
|
||||||
component="img"
|
component="img"
|
||||||
src={`/api/1.0/candidates/qr-code/${candidate.id || ''}`}
|
src={`/api/1.0/candidates/qr-code/${candidate.id || ''}/${(job && job.id) || ''}`}
|
||||||
alt="QR Code"
|
alt="QR Code"
|
||||||
className="qr-code"
|
className="qr-code"
|
||||||
sx={{ display: 'flex', mt: 1, mb: 1 }}
|
sx={{ display: 'flex', mt: 1, mb: 1 }}
|
||||||
/>
|
/>
|
||||||
{candidate?.username
|
{candidate?.username
|
||||||
? `https://backstory.ketrenos.com/u/${candidate?.username}`
|
? `${window.location.protocol}://${window.location.host}/u/${candidate?.username}`
|
||||||
: 'backstory'}
|
: 'backstory'}
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ pb: 2 }}> </Box>
|
<Box sx={{ pb: 2 }}> </Box>
|
||||||
@ -537,7 +538,7 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
|
|||||||
const [status, setStatus] = useState<string>('');
|
const [status, setStatus] = useState<string>('');
|
||||||
const [statusType, setStatusType] = useState<Types.ApiActivityType | null>(null);
|
const [statusType, setStatusType] = useState<Types.ApiActivityType | null>(null);
|
||||||
const [error, setError] = useState<Types.ChatMessageError | null>(null);
|
const [error, setError] = useState<Types.ChatMessageError | null>(null);
|
||||||
const [selectedStyle, setSelectedStyle] = useState<string>('modern');
|
const [selectedStyle, setSelectedStyle] = useState<string>('corporate');
|
||||||
|
|
||||||
const printContentRef = useRef<HTMLDivElement>(null);
|
const printContentRef = useRef<HTMLDivElement>(null);
|
||||||
const reactToPrintFn = useReactToPrint({
|
const reactToPrintFn = useReactToPrint({
|
||||||
@ -785,13 +786,11 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
|
|||||||
avatar={<DescriptionIcon color="success" />}
|
avatar={<DescriptionIcon color="success" />}
|
||||||
sx={{ p: 0, pb: 1 }}
|
sx={{ p: 0, pb: 1 }}
|
||||||
action={
|
action={
|
||||||
isAdmin && (
|
<Tooltip title="Edit Resume Content">
|
||||||
<Tooltip title="Edit Resume Content">
|
<IconButton size="small" onClick={handleEditOpen}>
|
||||||
<IconButton size="small" onClick={handleEditOpen}>
|
<EditIcon />
|
||||||
<EditIcon />
|
</IconButton>
|
||||||
</IconButton>
|
</Tooltip>
|
||||||
</Tooltip>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<CardContent sx={{ p: 0 }}>
|
<CardContent sx={{ p: 0 }}>
|
||||||
@ -899,19 +898,76 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
|
|||||||
fullScreen={true}
|
fullScreen={true}
|
||||||
>
|
>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
Edit Resume Content
|
<Box sx={{ display: 'flex', flexDirection: 'row', gap: 1 }}>
|
||||||
<Typography variant="caption" display="block" color="text.secondary">
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, m: 0, p: 0 }}>
|
||||||
Resume for {activeResume.candidate?.fullName || activeResume.candidateId},{' '}
|
Edit Resume Content
|
||||||
{activeResume.job?.title || 'No Job Title Assigned'},{' '}
|
<Typography variant="caption" display="block" color="text.secondary">
|
||||||
{activeResume.job?.company || 'No Company Assigned'}
|
Resume for {activeResume.candidate?.fullName || activeResume.candidateId},{' '}
|
||||||
</Typography>
|
{activeResume.job?.title || 'No Job Title Assigned'},{' '}
|
||||||
<Typography variant="caption" display="block" color="text.secondary">
|
{activeResume.job?.company || 'No Company Assigned'}
|
||||||
Resume ID: # {activeResume.id}
|
</Typography>
|
||||||
</Typography>
|
<Typography variant="caption" display="block" color="text.secondary">
|
||||||
<Typography variant="caption" display="block" color="text.secondary">
|
Resume ID: # {activeResume.id}
|
||||||
Last saved:{' '}
|
</Typography>
|
||||||
{activeResume.updatedAt ? new Date(activeResume.updatedAt).toLocaleString() : 'N/A'}
|
<Typography variant="caption" display="block" color="text.secondary">
|
||||||
</Typography>
|
Last saved:{' '}
|
||||||
|
{activeResume.updatedAt ? new Date(activeResume.updatedAt).toLocaleString() : 'N/A'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexGrow: 1,
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||||
|
{/* Style Selector */}
|
||||||
|
<FormControl size="small" sx={{ minWidth: 'min-content' }}>
|
||||||
|
<InputLabel id="resume-style-label">Resume Style</InputLabel>
|
||||||
|
<Select
|
||||||
|
labelId="resume-style-label"
|
||||||
|
value={selectedStyle}
|
||||||
|
onChange={e => setSelectedStyle(e.target.value)}
|
||||||
|
label="Resume Style"
|
||||||
|
>
|
||||||
|
{Object.entries(resumeStyles).map(([key, style]) => (
|
||||||
|
<MenuItem key={key} value={key}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="body2" fontWeight="bold">
|
||||||
|
{style.name}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{style.description}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Tabs value={tabValue} onChange={handleTabChange}>
|
||||||
|
<Tab value="markdown" icon={<EditDocumentIcon />} label="Markdown" />
|
||||||
|
{activeResume.systemPrompt && (
|
||||||
|
<Tab value="systemPrompt" icon={<TuneIcon />} label="System Prompt" />
|
||||||
|
)}
|
||||||
|
{activeResume.systemPrompt && (
|
||||||
|
<Tab value="prompt" icon={<InputIcon />} label="Prompt" />
|
||||||
|
)}
|
||||||
|
<Tab value="preview" icon={<PreviewIcon />} label="Preview" />
|
||||||
|
<Tab value="print" icon={<PrintIcon />} label="Print" />
|
||||||
|
<Tab value="regenerate" icon={<ModelTraining />} label="Regenerate" />
|
||||||
|
</Tabs>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
sx={{
|
sx={{
|
||||||
@ -945,65 +1001,6 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
|
|||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
|
|
||||||
<Tabs value={tabValue} onChange={handleTabChange}>
|
|
||||||
<Tab value="markdown" icon={<EditDocumentIcon />} label="Markdown" />
|
|
||||||
{activeResume.systemPrompt && (
|
|
||||||
<Tab value="systemPrompt" icon={<TuneIcon />} label="System Prompt" />
|
|
||||||
)}
|
|
||||||
{activeResume.systemPrompt && (
|
|
||||||
<Tab value="prompt" icon={<InputIcon />} label="Prompt" />
|
|
||||||
)}
|
|
||||||
<Tab value="preview" icon={<PreviewIcon />} label="Preview" />
|
|
||||||
<Tab value="print" icon={<PrintIcon />} label="Print" />
|
|
||||||
<Tab value="regenerate" icon={<ModelTraining />} label="Regenerate" />
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
{/* Style Selector */}
|
|
||||||
<FormControl size="small" sx={{ minWidth: 180 }}>
|
|
||||||
<InputLabel id="resume-style-label">
|
|
||||||
<StyleIcon sx={{ mr: 1, fontSize: 16 }} />
|
|
||||||
Resume Style
|
|
||||||
</InputLabel>
|
|
||||||
<Select
|
|
||||||
labelId="resume-style-label"
|
|
||||||
value={selectedStyle}
|
|
||||||
onChange={e => setSelectedStyle(e.target.value)}
|
|
||||||
label="Resume Style"
|
|
||||||
>
|
|
||||||
{Object.entries(resumeStyles).map(([key, style]) => (
|
|
||||||
<MenuItem key={key} value={key}>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'flex-start',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="body2" fontWeight="bold">
|
|
||||||
{style.name}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
{style.description}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
{/* Style Preview Chip */}
|
|
||||||
<Chip
|
|
||||||
icon={<StyleIcon />}
|
|
||||||
label={currentStyle.name}
|
|
||||||
sx={{
|
|
||||||
backgroundColor: currentStyle.color.primary,
|
|
||||||
color: '#ffffff',
|
|
||||||
fontWeight: 'bold',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{status && (
|
{status && (
|
||||||
<Box sx={{ mt: 0, mb: 1 }}>
|
<Box sx={{ mt: 0, mb: 1 }}>
|
||||||
<StatusBox>
|
<StatusBox>
|
||||||
@ -1117,8 +1114,12 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* QR Code Footer */}
|
{/* QR Code Footer */}
|
||||||
{activeResume.candidate && (
|
{activeResume.candidate && activeResume.job && (
|
||||||
<StyledFooter candidate={activeResume.candidate} style={currentStyle} />
|
<StyledFooter
|
||||||
|
candidate={activeResume.candidate}
|
||||||
|
job={activeResume.job}
|
||||||
|
style={currentStyle}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
@ -2,6 +2,8 @@ import os
|
|||||||
|
|
||||||
ollama_api_url = "http://ollama:11434" # Default Ollama local endpoint
|
ollama_api_url = "http://ollama:11434" # Default Ollama local endpoint
|
||||||
|
|
||||||
|
frontend_url = os.getenv("FRONTEND_URL", "https://backstory.ketrenos.com")
|
||||||
|
|
||||||
user_dir = "/opt/backstory/users"
|
user_dir = "/opt/backstory/users"
|
||||||
user_info_file = "info.json" # Relative to "{user_dir}/{user}"
|
user_info_file = "info.json" # Relative to "{user_dir}/{user}"
|
||||||
default_username = "jketreno"
|
default_username = "jketreno"
|
||||||
@ -16,8 +18,8 @@ persist_directory = "db" # Relative to "{user_dir}/{user}"
|
|||||||
# model = "gemma3:4b" # Gemma Requires newer ollama https://ai.google.dev/gemma/terms
|
# model = "gemma3:4b" # Gemma Requires newer ollama https://ai.google.dev/gemma/terms
|
||||||
# model = "llama3.2" # Llama Good results; qwen seems slightly better https://huggingface.co/meta-llama/Llama-3.2-1B/blob/main/LICENSE.txt
|
# model = "llama3.2" # Llama Good results; qwen seems slightly better https://huggingface.co/meta-llama/Llama-3.2-1B/blob/main/LICENSE.txt
|
||||||
# model = "mistral:7b" # Apache 2.0 Tool calls don"t work
|
# model = "mistral:7b" # Apache 2.0 Tool calls don"t work
|
||||||
|
# model = "qwen3:8b" # Apache 2.0 Requires newer ollama
|
||||||
model = "qwen2.5:7b" # Apache 2.0 Good results
|
model = "qwen2.5:7b" # Apache 2.0 Good results
|
||||||
# model = "qwen3:8b" # Apache 2.0 Requires newer ollama
|
|
||||||
model = os.getenv("MODEL_NAME", model)
|
model = os.getenv("MODEL_NAME", model)
|
||||||
|
|
||||||
# Embedding model for producing vectors to use in RAG
|
# Embedding model for producing vectors to use in RAG
|
||||||
|
@ -1170,7 +1170,7 @@ async def verify_email(
|
|||||||
dir_path = os.path.join(defines.user_dir, username)
|
dir_path = os.path.join(defines.user_dir, username)
|
||||||
if not os.path.exists(dir_path):
|
if not os.path.exists(dir_path):
|
||||||
os.makedirs(dir_path, exist_ok=True)
|
os.makedirs(dir_path, exist_ok=True)
|
||||||
qrobj = pyqrcode.create(f"https://backstory.ketrenos.com/u/{username}")
|
qrobj = pyqrcode.create(f"{defines.frontend_url}/u/{username}")
|
||||||
dir_path = os.path.join(dir_path, "qrcode.png")
|
dir_path = os.path.join(dir_path, "qrcode.png")
|
||||||
with open(dir_path, "wb") as f:
|
with open(dir_path, "wb") as f:
|
||||||
qrobj.png(f, scale=2)
|
qrobj.png(f, scale=2)
|
||||||
|
@ -714,9 +714,10 @@ async def get_candidate_profile_image(
|
|||||||
status_code=500, content=create_error_response("FETCH_ERROR", "Failed to retrieve profile image")
|
status_code=500, content=create_error_response("FETCH_ERROR", "Failed to retrieve profile image")
|
||||||
)
|
)
|
||||||
|
|
||||||
@router.get("/qr-code/{candidate_id}")
|
@router.get("/qr-code/{candidate_id}/{job_id}")
|
||||||
async def get_candidate_qr_code(
|
async def get_candidate_qr_code(
|
||||||
candidate_id: str = Path(..., description="ID of the candidate"),
|
candidate_id: str = Path(..., description="ID of the candidate"),
|
||||||
|
job_id: Optional[str] = Path(..., description="ID of the candidate"),
|
||||||
# current_user = Depends(get_current_user),
|
# current_user = Depends(get_current_user),
|
||||||
database: RedisDatabase = Depends(get_database),
|
database: RedisDatabase = Depends(get_database),
|
||||||
):
|
):
|
||||||
@ -735,13 +736,29 @@ async def get_candidate_qr_code(
|
|||||||
return JSONResponse(status_code=404, content=create_error_response("NOT_FOUND", "Candidate not found"))
|
return JSONResponse(status_code=404, content=create_error_response("NOT_FOUND", "Candidate not found"))
|
||||||
|
|
||||||
candidate = Candidate.model_validate(candidates_list[0])
|
candidate = Candidate.model_validate(candidates_list[0])
|
||||||
file_path = os.path.join(defines.user_dir, candidate.username, "qrcode.png")
|
|
||||||
file_path = pathlib.Path(file_path)
|
job = None
|
||||||
|
if job_id:
|
||||||
|
job_data = await database.get_job(job_id)
|
||||||
|
if not job_data:
|
||||||
|
logger.warning(f"⚠️ Job not found for ID: {job_id}")
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=404,
|
||||||
|
content=create_error_response("JOB_NOT_FOUND", f"Job with id '{job_id}' not found"),
|
||||||
|
)
|
||||||
|
job = Job.model_validate(job_data)
|
||||||
|
|
||||||
|
file_name = f"{job.id}.png" if job else "qrcode.png"
|
||||||
|
file_path = pathlib.Path(defines.user_dir) / candidate.username / file_name
|
||||||
if not file_path.exists():
|
if not file_path.exists():
|
||||||
logger.error(f"❌ QR code not found on disk: {file_path}")
|
import pyqrcode
|
||||||
return JSONResponse(
|
|
||||||
status_code=404, content=create_error_response("FILE_NOT_FOUND", "QR code not found on disk")
|
if job:
|
||||||
)
|
qrobj = pyqrcode.create(f"{defines.frontend_url}/job-analysis/{candidate.id}/{job.id}")
|
||||||
|
else:
|
||||||
|
qrobj = pyqrcode.create(f"{defines.frontend_url}/u/{candidate.id}")
|
||||||
|
with open(file_path, "wb") as f:
|
||||||
|
qrobj.png(f, scale=2)
|
||||||
return FileResponse(
|
return FileResponse(
|
||||||
file_path,
|
file_path,
|
||||||
media_type=f"image/{file_path.suffix[1:]}", # Get extension without dot
|
media_type=f"image/{file_path.suffix[1:]}", # Get extension without dot
|
||||||
|
Loading…
x
Reference in New Issue
Block a user