Compare commits

...

64 Commits

Author SHA1 Message Date
administrator 27e51a80d2 Initial commit 2025-05-22 16:02:23 +05:30
raman 90f50950fd leave balance in dashboard 2025-05-22 16:02:23 +05:30
Pranay d2f1caf810 Discuss Update 2025-05-22 16:02:23 +05:30
Pranay 995688ad8e Leaves stopped auto allocation Mailing 2025-05-22 16:02:23 +05:30
raman efc7643304 birthday reminder 2025-05-22 16:02:23 +05:30
Pranay 55178e4b15 Discuss Messages 2025-05-22 16:02:23 +05:30
karuna 50daabba44 ATS ATTACHMENTS 2025-05-22 16:02:23 +05:30
Pranay 6cb1d7526e ODOO FLUTTER 2025-05-22 16:02:23 +05:30
Pranay 1424e147e8 CWF TIMESHEET added Month 2025-05-22 16:02:23 +05:30
Pranay 2aabe1e7c6 Recruitment changes 2025-05-22 16:02:23 +05:30
Pranay 426b3d71a4 Time off changes 2025-05-22 16:02:23 +05:30
Pranay 62df996ae4 CWF Timesheet Access rights 2025-05-22 16:02:22 +05:30
Pranay 11066ba9c1 Time off submission issue 2025-05-22 16:02:22 +05:30
praveen 09d68ec225 button fix 2025-05-22 16:02:22 +05:30
praveen a77bb66622 changring buttons 2025-05-22 16:02:22 +05:30
raman b34dcf6d5b FIX BUG 2025-05-22 16:02:22 +05:30
administrator af98a28428 live cap fix 2025-05-22 16:02:22 +05:30
Pranay 7b91e82363 Fix Change Candidate _inverse_partner_email 2025-05-22 16:02:22 +05:30
raman c225eeec5d bug fix 2025-05-22 16:02:22 +05:30
raman be0cbc33bb fix preview 2025-05-22 16:02:22 +05:30
Pranay 2e53ada2f3 ATS Changes added target date 2025-05-22 16:02:22 +05:30
Pranay baa6dec075 ATS CHANGES 2025-05-22 16:02:22 +05:30
Pranay c9555b962e payroll management timeoff 2025-05-22 16:02:21 +05:30
raman 08f388019f New Modules 2025-05-22 16:02:21 +05:30
Pranay 743fb36d90 recruitment changes 2025-05-22 16:02:21 +05:30
raman 3d2c673050 Report FIX 2025-05-22 16:02:21 +05:30
Pranay 7a6352ee21 TimeOff Fix 2025-05-22 16:02:21 +05:30
Pranay 27eed9ee63 time-off FIX 2025-05-22 16:02:21 +05:30
Pranay 909bf116b6 CWF Attendance Fix 2025-05-22 16:02:21 +05:30
Pranay fdc9f88129 Removed Old stage in recruitment and website description Fix 2025-05-22 16:02:20 +05:30
Pranay 448385fd45 Recruitment & Attendance module changes 2025-05-22 16:02:20 +05:30
raman 351180260a dash board fix 2025-05-22 16:02:20 +05:30
Pranay e489072aae recruitment Changes and fixes 2025-05-22 16:02:20 +05:30
raman 4e44c708d9 FIX Dashboard 2025-05-22 16:02:20 +05:30
raman 2188551da3 FIX bug in att 2025-05-22 16:02:20 +05:30
raman 953794adc2 indian payroll 2025-05-22 16:02:20 +05:30
raman adb500009f knowledge module 2025-05-22 16:02:20 +05:30
Pranay eee154da0c New Module Documents and Fix Recruitment 2025-05-22 16:02:19 +05:30
Pranay a072f600e6 RECRUITMENT Changes and Fixes 2025-05-22 16:02:19 +05:30
raman 87028c063a cwf time sheets 2025-05-22 16:02:19 +05:30
Pranay e24e329b27 Recruitment Changes 2025-05-22 16:02:19 +05:30
Pranay c8902cd7f2 fix whatsapp 2025-05-22 16:02:19 +05:30
Pranay 044e01aa23 update whatsapp code 2025-05-22 16:02:19 +05:30
Pranay a42d6449cf FIX; Whatsapp issue 2025-05-22 16:02:19 +05:30
administrator a801a30488 Merge remote-tracking branch 'refs/remotes/origin/develop' into develop 2025-03-21 10:59:36 +05:30
administrator e32ad6cc9b Initial commit 2025-03-21 10:59:32 +05:30
administrator 758c3dc10f Initial commit 2025-03-21 10:59:32 +05:30
raman 2342ded3ff fix issues 2025-03-21 10:59:32 +05:30
Pranay 38080d56c0 Recruitment Changes 2025-03-21 10:59:32 +05:30
shankar 2288404bc2 Target change to Number of Positions 2025-03-21 10:59:32 +05:30
shankar 9f3d07d7be Change to Expeted Skills TO Primary Skills 2025-03-21 10:59:32 +05:30
Pranay a8bc538da4 fix dependencies HR Recruitment extended 2025-03-21 10:59:32 +05:30
Pranay 91b7f05c45 Employees family, education, and other fields added 2025-03-21 10:59:32 +05:30
Pranay b7f5f4ec3e HR RECRUITMENT MODULE(NEW) 2025-03-21 10:59:30 +05:30
administrator a79a1a7b0e Initial commit 2025-03-21 10:59:11 +05:30
administrator 2033d5c227 Initial commit 2025-03-21 10:59:11 +05:30
raman cabb3c85a1 fix issues 2025-03-21 10:56:43 +05:30
Pranay a64fdf9a43 Recruitment Changes 2025-03-20 19:03:52 +05:30
shankar a9650eb637 Target change to Number of Positions 2025-03-13 12:00:17 +05:30
shankar dec0db69f2 Change to Expeted Skills TO Primary Skills 2025-03-12 18:26:43 +05:30
Pranay a421881fda fix dependencies HR Recruitment extended 2025-03-12 17:58:42 +05:30
Pranay 56179c5d18 Employees family, education, and other fields added 2025-03-12 17:44:06 +05:30
Pranay 106171103a HR RECRUITMENT MODULE(NEW) 2025-03-12 17:24:52 +05:30
administrator 9631ef27b3 Merge pull request 'test' (#1) from test into feature/odoo18
Reviewed-on: https://gitea.ftprotech.in/administrator/odoo18/pulls/1
2025-03-11 14:31:53 +05:30
1310 changed files with 831936 additions and 1329 deletions

View File

@ -0,0 +1 @@
from . import models

View File

@ -0,0 +1,22 @@
{
'name': 'CWF Timesheet Update',
'version': '1.0',
'category': 'Human Resources',
'summary': 'Manage and update weekly timesheets for CWF department',
'author': 'Your Name or Company',
'depends': ['hr_attendance_extended','web', 'mail', 'base','hr_emp_dashboard','hr_employee_extended'],
'data': [
# 'views/timesheet_form.xml',
'security/security.xml',
'security/ir.model.access.csv',
'views/timesheet_view.xml',
'views/timesheet_weekly_view.xml',
'data/email_template.xml',
],
'assets': {
'web.assets_backend': [
'cwf_timesheet/static/src/js/timesheet_form.js',
],
},
'application': True,
}

View File

@ -0,0 +1,9 @@
from odoo import http
from odoo.http import request
class TimesheetController(http.Controller):
@http.route('/timesheet/form', auth='user', website=True)
def timesheet_form(self, **kw):
# This will render the template for the timesheet form
return request.render('timesheet_form', {})

View File

@ -0,0 +1,45 @@
<odoo>
<data noupdate="0">
<record id="email_template_timesheet_weekly_update" model="mail.template">
<field name="name">Timesheet Update Reminder</field>
<field name="model_id" ref="cwf_timesheet.model_cwf_weekly_timesheet"/>
<field name="email_from">{{ user.email_formatted }}</field>
<field name="email_to">{{ object.employee_id.user_id.email }}</field>
<field name="subject">Reminder: Update Your Weekly Timesheet</field>
<field name="description">
Reminder to employee to update their weekly timesheet.
</field>
<field name="body_html" type="html">
<p style="margin: 0px; padding: 0px; font-size: 13px;">
Dear <t t-esc="ctx['employee_name']">Employee</t>,
<br/>
<br/>
I hope this message finds you in good spirits. I would like to remind you to please update your weekly timesheet for the period from
<strong>
<t t-esc="ctx['week_from']"/>
</strong>
to
<strong>
<t t-esc="ctx['week_to']"/>
</strong>.
Timely updates are crucial for maintaining accurate records and ensuring smooth processing.
<br/>
<br/>
To make things easier, you can use the link below to update your timesheet:
<br/>
<a href="https://ftprotech.in/odoo/action-261" class="cta-button" target="_blank">Update Timesheet</a>
<br/>
<br/>
Thank you for your attention.
<br/>
Best regards,
<br/>
<strong>Fast Track Project Pvt Ltd.</strong>
<br/>
<br/>
<a href="https://ftprotech.in/" target="_blank">Visit our site</a> for more information.
</p>
</field>
</record>
</data>
</odoo>

View File

@ -0,0 +1 @@
from . import timesheet

View File

@ -0,0 +1,399 @@
from odoo import models, fields, api
from odoo.exceptions import ValidationError, UserError
from datetime import datetime, timedelta
import datetime as dt
from odoo import _
from calendar import month_name, month
from datetime import date
class CwfTimesheetYearly(models.Model):
_name = 'cwf.timesheet.calendar'
_description = "CWF Timesheet Calendar"
_rec_name = 'name'
name = fields.Char(string='Year Name', required=True)
week_period = fields.One2many('cwf.timesheet','cwf_calendar_id')
_sql_constraints = [
('unique_year', 'unique(name)', 'The year must be unique!')
]
@api.constrains('name')
def _check_year_format(self):
for record in self:
if not record.name.isdigit() or len(record.name) != 4:
raise ValidationError("Year Name must be a 4-digit number.")
def generate_week_period(self):
for record in self:
record.week_period.unlink()
year = int(record.name)
# Find the first Monday of the year
start_date = datetime(year, 1, 1)
while start_date.weekday() != 0: # Monday is 0 in weekday()
start_date += timedelta(days=1)
# Generate weeks from Monday to Sunday
while start_date.year == year or (start_date - timedelta(days=1)).year == year:
end_date = start_date + timedelta(days=6)
self.env['cwf.timesheet'].create({
'name': f'Week {start_date.strftime("%W")}, {year}',
'week_start_date': start_date.date(),
'week_end_date': end_date.date(),
'cwf_calendar_id': record.id,
})
start_date += timedelta(days=7)
def action_generate_weeks(self):
self.generate_week_period()
return {
'type': 'ir.actions.client',
'tag': 'reload',
}
class CwfTimesheet(models.Model):
_name = 'cwf.timesheet'
_description = 'CWF Weekly Timesheet'
_rec_name = 'name'
name = fields.Char(string='Week Name', required=True)
week_start_date = fields.Date(string='Week Start Date', required=True)
week_end_date = fields.Date(string='Week End Date', required=True)
status = fields.Selection([
('draft', 'Draft'),
('submitted', 'Submitted')
], default='draft', string='Status')
lines = fields.One2many('cwf.timesheet.line','week_id')
cwf_calendar_id = fields.Many2one('cwf.timesheet.calendar')
start_month = fields.Selection(
[(str(i), month_name[i]) for i in range(1, 13)],
string='Start Month',
compute='_compute_months',
store=True
)
end_month = fields.Selection(
[(str(i), month_name[i]) for i in range(1, 13)],
string='End Month',
compute='_compute_months',
store=True
)
@api.depends('week_start_date', 'week_end_date')
def _compute_months(self):
for rec in self:
if rec.week_start_date:
rec.start_month = str(rec.week_start_date.month)
else:
rec.start_month = False
if rec.week_end_date:
rec.end_month = str(rec.week_end_date.month)
else:
rec.end_month = False
@api.depends('name','week_start_date','week_end_date')
def _compute_display_name(self):
for rec in self:
rec.display_name = rec.name if not rec.week_start_date and rec.week_end_date else "%s (%s - %s)"%(rec.name,rec.week_start_date.strftime('%-d %b'), rec.week_end_date.strftime('%-d %b') )
def send_timesheet_update_email(self):
template = self.env.ref('cwf_timesheet.email_template_timesheet_weekly_update')
# Ensure that we have a valid employee email
current_date = fields.Date.from_string(self.week_start_date)
end_date = fields.Date.from_string(self.week_end_date)
if current_date > end_date:
raise UserError('The start date cannot be after the end date.')
# Get all employees in the department
external_group_id = self.env.ref("hr_employee_extended.group_external_user")
users = self.env["res.users"].search([("groups_id", "=", external_group_id.id)])
employees = self.env['hr.employee'].search([('user_id', 'in', users.ids),'|',('doj','=',False),('doj','>=', self.week_start_date)])
print(employees)
# Loop through each day of the week and create timesheet lines for each employee
while current_date <= end_date:
for employee in employees:
existing_record = self.env['cwf.weekly.timesheet'].sudo().search([
('week_id', '=', self.id),
('employee_id', '=', employee.id)
], limit=1)
if not existing_record:
self.env['cwf.timesheet.line'].sudo().create({
'week_id': self.id,
'employee_id': employee.id,
'week_day':current_date,
})
current_date += timedelta(days=1)
self.status = 'submitted'
for employee in employees:
weekly_timesheet_exists = self.env['cwf.weekly.timesheet'].sudo().search([('week_id','=',self.id),('employee_id','=',employee.id)])
if not weekly_timesheet_exists:
weekly_timesheet = self.env['cwf.weekly.timesheet'].sudo().create({
'week_id': self.id,
'employee_id': employee.id,
'status': 'draft'
})
else:
weekly_timesheet = weekly_timesheet_exists
# Generate the URL for the newly created weekly_timesheet
base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url')
record_url = f"{base_url}/web#id={weekly_timesheet.id}&view_type=form&model=cwf.weekly.timesheet"
weekly_timesheet.update_attendance()
if employee.work_email and weekly_timesheet:
email_values = {
'email_to': employee.work_email, # Email body from template
'subject': 'Timesheet Update Notification',
}
body_html = template.body_html.replace(
'https://ftprotech.in/odoo/action-261',
record_url
),
render_ctx = {'employee_name':weekly_timesheet.employee_id.name,'week_from':weekly_timesheet.week_id.week_start_date,'week_to':weekly_timesheet.week_id.week_end_date}
template.with_context(default_body_html=body_html,**render_ctx).send_mail(weekly_timesheet.id, email_values=email_values, force_send=True)
class CwfWeeklyTimesheet(models.Model):
_name = "cwf.weekly.timesheet"
_description = "CWF Weekly Timesheet"
_rec_name = 'employee_id'
def _default_week_id(self):
current_date = fields.Date.today()
timesheet = self.env['cwf.timesheet'].sudo().search([
('week_start_date', '<=', current_date),
('week_end_date', '>=', current_date)
], limit=1)
return timesheet.id if timesheet else False
def _get_week_id_domain(self):
for rec in self:
return [('week_start_date.month_number','=',2)]
pass
month_id = fields.Selection(
[(str(i), month_name[i]) for i in range(1, 13)],
string='Month'
)
week_id = fields.Many2one('cwf.timesheet', 'Week', default=lambda self: self._default_week_id())
employee_id = fields.Many2one('hr.employee', default=lambda self: self.env.user.employee_id.id)
cwf_timesheet_lines = fields.One2many('cwf.timesheet.line' ,'weekly_timesheet')
status = fields.Selection([('draft','Draft'),('submitted','Submitted')], default='draft')
week_start_date = fields.Date(related='week_id.week_start_date')
week_end_date = fields.Date(related='week_id.week_end_date')
@api.onchange('month_id')
def _onchange_month(self):
if self.month_id:
year = self.week_start_date.year if self.week_start_date else fields.Date.today().year
start = date(year, int(self.month_id), 1)
if int(self.month_id) == 12:
end = date(year + 1, 1, 1) - timedelta(days=1)
else:
end = date(year, int(self.month_id) + 1, 1) - timedelta(days=1)
self = self.with_context(month_start=start, month_end=end)
@api.onchange('week_id')
def _onchange_week_id(self):
if self.week_id and self.week_id.week_start_date:
self.month_id = str(self.week_id.week_start_date.month)
@api.constrains('week_id', 'employee_id')
def _check_unique_week_employee(self):
for record in self:
if record.week_id.week_start_date > fields.Date.today():
raise ValidationError(_("You Can't select future week period"))
# Search for existing records with the same week_id and employee_id
existing_record = self.env['cwf.weekly.timesheet'].search([
('week_id', '=', record.week_id.id),
('employee_id', '=', record.employee_id.id)
], limit=1)
# If an existing record is found and it's not the current record (in case of update), raise an error
if existing_record and existing_record.id != record.id:
raise ValidationError("A timesheet for this employee already exists for the selected week.")
def update_attendance(self):
for rec in self:
# Get the week start and end date
week_start_date = rec.week_id.week_start_date
week_end_date = rec.week_id.week_end_date
# Convert start and end dates to datetime objects for proper filtering
week_start_datetime = datetime.combine(week_start_date, datetime.min.time())
week_end_datetime = datetime.combine(week_end_date, datetime.max.time())
# Delete timesheet lines that are outside the week range
rec.cwf_timesheet_lines.filtered(lambda line:
line.week_day < week_start_date or line.week_day > week_end_date
).unlink()
# Search for attendance records that fall within the week period and match the employee
hr_attendance_records = self.env['hr.attendance'].sudo().search([
('check_in', '>=', week_start_datetime),
('check_out', '<=', week_end_datetime),
('employee_id', '=', rec.employee_id.id)
])
# Group the attendance records by date
attendance_by_date = {}
for attendance in hr_attendance_records:
attendance_date = attendance.check_in.date()
if attendance_date not in attendance_by_date:
attendance_by_date[attendance_date] = []
attendance_by_date[attendance_date].append(attendance)
# Get all the dates within the week period
all_week_dates = [week_start_date + timedelta(days=i) for i in
range((week_end_date - week_start_date).days + 1)]
# Create or update timesheet lines for each day in the week
for date in all_week_dates:
# Check if there is attendance for this date
if date in attendance_by_date:
# If there are multiple attendance records, take the earliest check_in and latest check_out
earliest_check_in = min(attendance.check_in for attendance in attendance_by_date[date])
latest_check_out = max(attendance.check_out for attendance in attendance_by_date[date])
if (earliest_check_in + timedelta(hours=5, minutes=30)).date() > date:
earliest_check_in = (datetime.combine(date, datetime.max.time()) - timedelta(hours=5, minutes=30))
if (latest_check_out + timedelta(hours=5, minutes=30)).date() > date:
latest_check_out = (datetime.combine(date, datetime.max.time()) - timedelta(hours=5, minutes=30))
# Check if a timesheet line for this employee, week, and date already exists
existing_timesheet_line = self.env['cwf.timesheet.line'].sudo().search([
('week_day', '=', date),
('employee_id', '=', rec.employee_id.id),
('week_id', '=', rec.week_id.id),
('weekly_timesheet', '=', rec.id)
], limit=1)
if existing_timesheet_line:
# If it exists, update the existing record
existing_timesheet_line.write({
'check_in_date': earliest_check_in,
'check_out_date': latest_check_out,
'state_type': 'present',
})
else:
# If it doesn't exist, create a new timesheet line with present state_type
self.env['cwf.timesheet.line'].create({
'weekly_timesheet': rec.id,
'employee_id': rec.employee_id.id,
'week_id': rec.week_id.id,
'week_day': date,
'check_in_date': earliest_check_in,
'check_out_date': latest_check_out,
'state_type': 'present',
})
else:
# If no attendance exists for this date, create a new timesheet line with time_off state_type
existing_timesheet_line = self.env['cwf.timesheet.line'].sudo().search([
('week_day', '=', date),
('employee_id', '=', rec.employee_id.id),
('week_id', '=', rec.week_id.id),
('weekly_timesheet', '=', rec.id)
], limit=1)
if not existing_timesheet_line:
if date.weekday() != 5 and date.weekday() != 6:
# If no record exists for this date, create a new timesheet line with time_off state_type
self.env['cwf.timesheet.line'].create({
'weekly_timesheet': rec.id,
'employee_id': rec.employee_id.id,
'week_id': rec.week_id.id,
'week_day': date,
'state_type': 'time_off',
})
def action_submit(self):
for rec in self:
for timesheet in rec.cwf_timesheet_lines:
timesheet.action_submit()
rec.status = 'submitted'
class CwfTimesheetLine(models.Model):
_name = 'cwf.timesheet.line'
_description = 'CWF Weekly Timesheet Lines'
_rec_name = 'employee_id'
weekly_timesheet = fields.Many2one('cwf.weekly.timesheet')
employee_id = fields.Many2one('hr.employee', string='Employee', related='weekly_timesheet.employee_id')
week_id = fields.Many2one('cwf.timesheet', 'Week', related='weekly_timesheet.week_id')
week_day = fields.Date(string='Date')
check_in_date = fields.Datetime(string='Checkin')
check_out_date = fields.Datetime(string='Checkout ')
is_updated = fields.Boolean('Attendance Updated')
state_type = fields.Selection([('draft','Draft'),('holiday', 'Holiday'),('time_off','Time Off'),('half_day','Half Day'),('present','Present')], default='draft', required=True)
@api.constrains('week_day', 'check_in_date', 'check_out_date')
def _check_week_day_and_times(self):
for record in self:
# Ensure week_day is within the week range
if record.week_id:
if record.week_day < record.week_id.week_start_date or record.week_day > record.week_id.week_end_date:
raise ValidationError(
"The selected 'week_day' must be within the range of the week from %s to %s." %
(record.week_id.week_start_date, record.week_id.week_end_date)
)
# Ensure check_in_date and check_out_date are on the selected week_day
if record.check_in_date:
if record.check_in_date.date() != record.week_day:
raise ValidationError(
"The 'check_in_date' must be on the selected Date."
)
if record.check_out_date:
if record.check_out_date.date() != record.week_day:
raise ValidationError(
"The 'check_out_date' must be on the selected Date."
)
def action_submit(self):
if self.state_type == 'draft' or not self.state_type:
raise ValidationError(_('State type should not Draft or Empty'))
if self.state_type not in ['holiday','time_off'] and not (self.check_in_date or self.check_out_date):
raise ValidationError(_('Please enter Check details'))
self._update_attendance()
def _update_attendance(self):
attendance_obj = self.env['hr.attendance']
for record in self:
if record.check_in_date != False and record.check_out_date != False and record.employee_id:
first_check_in = attendance_obj.sudo().search([('check_in', '>=', record.check_in_date.date()),
('check_out', '<=', record.check_out_date.date()),('employee_id','=',record.employee_id.id)],
limit=1, order="check_in")
last_check_out = attendance_obj.sudo().search([('check_in', '>=', record.check_in_date.date()),
('check_out', '<=', record.check_out_date.date()),('employee_id','=',record.employee_id.id)],
limit=1, order="check_out desc")
if first_check_in or last_check_out:
if first_check_in.sudo().check_in != record.check_in_date:
first_check_in.sudo().check_in = record.check_in_date
if last_check_out.sudo().check_out != record.check_out_date:
last_check_out.sudo().check_out = record.check_out_date
else:
attendance_obj.sudo().create({
'employee_id': record.employee_id.id,
'check_in': record.check_in_date - timedelta(hours=5, minutes=30),
'check_out': record.check_out_date - timedelta(hours=5, minutes=30),
})
record.is_updated = True

View File

@ -0,0 +1,15 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_cwf_timesheet_user,access.cwf.timesheet,model_cwf_timesheet,base.group_user,1,0,0,0
access_cwf_timesheet_manager,access.cwf.timesheet,model_cwf_timesheet,hr_attendance.group_hr_attendance_manager,1,1,1,1
access_cwf_timesheet_calendar,cwf_timesheet_calendar,model_cwf_timesheet_calendar,hr_attendance.group_hr_attendance_manager,1,1,1,1
access_cwf_timesheet_calendar_user,cwf_timesheet_calendar_user,model_cwf_timesheet_calendar,base.group_user,1,0,0,0
access_cwf_timesheet_line_manager,access.cwf.timesheet.line.manager,model_cwf_timesheet_line,hr_attendance.group_hr_attendance_manager,1,1,1,1
access_cwf_timesheet_line_user,access.cwf.timesheet.line,model_cwf_timesheet_line,hr_employee_extended.group_external_user,1,1,1,1
access_cwf_weekly_timesheet_manager,cwf.weekly.timesheet.manager access,model_cwf_weekly_timesheet,hr_attendance.group_hr_attendance_manager,1,1,1,1
access_cwf_weekly_timesheet_user,cwf.weekly.timesheet access,model_cwf_weekly_timesheet,hr_employee_extended.group_external_user,1,1,1,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_cwf_timesheet_user access.cwf.timesheet model_cwf_timesheet base.group_user 1 0 0 0
3 access_cwf_timesheet_manager access.cwf.timesheet model_cwf_timesheet hr_attendance.group_hr_attendance_manager 1 1 1 1
4 access_cwf_timesheet_calendar cwf_timesheet_calendar model_cwf_timesheet_calendar hr_attendance.group_hr_attendance_manager 1 1 1 1
5 access_cwf_timesheet_calendar_user cwf_timesheet_calendar_user model_cwf_timesheet_calendar base.group_user 1 0 0 0
6 access_cwf_timesheet_line_manager access.cwf.timesheet.line.manager model_cwf_timesheet_line hr_attendance.group_hr_attendance_manager 1 1 1 1
7 access_cwf_timesheet_line_user access.cwf.timesheet.line model_cwf_timesheet_line hr_employee_extended.group_external_user 1 1 1 1
8 access_cwf_weekly_timesheet_manager cwf.weekly.timesheet.manager access model_cwf_weekly_timesheet hr_attendance.group_hr_attendance_manager 1 1 1 1
9 access_cwf_weekly_timesheet_user cwf.weekly.timesheet access model_cwf_weekly_timesheet hr_employee_extended.group_external_user 1 1 1 0

View File

@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<data noupdate="0">
<record id="cwf_weekly_timesheet_user_rule" model="ir.rule">
<field name="name">CWF Weekly Timesheet User Rule</field>
<field ref="cwf_timesheet.model_cwf_weekly_timesheet" name="model_id"/>
<field name="domain_force">[('employee_id.user_id.id','=',user.id)]</field>
<field name="groups" eval="[(4, ref('hr_employee_extended.group_external_user'))]"/>
</record>
<record id="cwf_timesheet_line_user_rule" model="ir.rule">
<field name="name">CWF Timesheet Line User Rule</field>
<field ref="cwf_timesheet.model_cwf_timesheet_line" name="model_id"/>
<field name="domain_force">[('employee_id.user_id.id','=',user.id)]</field>
<field name="groups" eval="[(4, ref('hr_employee_extended.group_external_user'))]"/>
</record>
<record id="cwf_weekly_timesheet_manager_rule" model="ir.rule">
<field name="name">CWF Weekly Timesheet manager Rule</field>
<field ref="cwf_timesheet.model_cwf_weekly_timesheet" name="model_id"/>
<field name="domain_force">['|',('employee_id.user_id.id','!=',user.id),('employee_id.user_id.id','=',user.id)]</field>
<field name="groups" eval="[(4, ref('hr_attendance.group_hr_attendance_manager'))]"/>
</record>
<record id="cwf_timesheet_line_manager_rule" model="ir.rule">
<field name="name">CWF Timesheet Line manager Rule</field>
<field ref="cwf_timesheet.model_cwf_timesheet_line" name="model_id"/>
<field name="domain_force">['|',('employee_id.user_id.id','!=',user.id),('employee_id.user_id.id','=',user.id)]</field>
<field name="groups" eval="[(4, ref('hr_attendance.group_hr_attendance_manager'))]"/>
</record>
</data>
</odoo>

View File

@ -0,0 +1,54 @@
/** @odoo-module **/
import { patch } from "@web/core/utils/patch";
import { NetflixProfileContainer } from "@hr_emp_dashboard/js/profile_component";
import { user } from "@web/core/user";
// Apply patch to NetflixProfileContainer prototype
patch(NetflixProfileContainer.prototype, {
/**
* @override
*/
setup() {
// Call parent setup method
super.setup(...arguments);
// Log the department of the logged-in employee (check if data is available)
// if (this.state && this.state.login_employee) {
// console.log(this.state.login_employee['department_id']);
// } else {
// console.error('Employee or department data is unavailable.');
// }
},
/**
* Override the hr_timesheets method
*/
async hr_timesheets() {
const isExternalUser = await user.hasGroup("hr_employee_extended.group_external_user");
// Check the department of the logged-in employee
console.log(isExternalUser);
console.log("is external user");
debugger;
if (isExternalUser && this.state.login_employee.department_id) {
console.log("hello external");
// If the department is 'CWF', perform the action to open the timesheets
this.action.doAction({
name: "Timesheets",
type: 'ir.actions.act_window',
res_model: 'cwf.timesheet.line', // Ensure this model exists
view_mode: 'list,form',
views: [[false, 'list'], [false, 'form']],
context: {
'search_default_week_id': true,
},
domain: [['employee_id.user_id','=', this.props.action.context.user_id]],
target: 'current',
});
} else {
// If not 'CWF', call the base functionality
return super.hr_timesheets();
}
},
});

View File

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="timesheet_form">
<div class="container">
<h2>Weekly Timesheet</h2>
<form class="timesheet-form">
<div class="form-group">
<label for="employee">Employee</label>
<input t-att-value="state.employee" type="text" id="employee" class="form-control"/>
</div>
<div class="form-group">
<label for="weekStartDate">Week Start Date</label>
<input type="datetime-local" t-model="state.weekStartDate" id="weekStartDate" class="form-control"/>
</div>
<div class="form-group">
<label for="weekEndDate">Week End Date</label>
<input type="datetime-local" t-model="state.weekEndDate" id="weekEndDate" class="form-control"/>
</div>
<div class="form-group">
<label for="totalHours">Total Hours Worked</label>
<input type="number" t-model="state.totalHours" id="totalHours" class="form-control" min="0"/>
</div>
<button type="button" t-on-click="submitForm" class="btn btn-primary">Submit Timesheet</button>
</form>
</div>
</t>
</templates>

View File

@ -0,0 +1,104 @@
<odoo>
<record id="view_cwf_timesheet_calendar_form" model="ir.ui.view">
<field name="name">cwf.timesheet.calendar.form</field>
<field name="model">cwf.timesheet.calendar</field>
<field name="arch" type="xml">
<form string="CWF Timesheet Calendar">
<sheet>
<group>
<field name="name"/>
<button name="action_generate_weeks" string="Generate Week Periods" type="object"
class="oe_highlight"/>
</group>
<notebook>
<page string="Weeks">
<field name="week_period" context="{'order': 'week_start_date asc'}">
<list editable="bottom" decoration-success="status == 'submitted'">
<field name="name"/>
<field name="week_start_date"/>
<field name="week_end_date"/>
<field name="start_month" column_invisible="1"/>
<field name="end_month" column_invisible="1"/>
<field name="status"/>
<button name="send_timesheet_update_email" string="Send Update Email"
invisible="status == 'submitted'" type="object"
confirm="You can't revert this action. Please check twice before Submitting?"
class="oe_highlight"/>
</list>
</field>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="view_cwf_timesheet_calendar_list" model="ir.ui.view">
<field name="name">cwf.timesheet.calendar.list</field>
<field name="model">cwf.timesheet.calendar</field>
<field name="arch" type="xml">
<list>
<field name="name"/>
</list>
</field>
</record>
<record id="action_cwf_timesheet_calendar" model="ir.actions.act_window">
<field name="name">CWF Timesheet Calendar</field>
<field name="res_model">cwf.timesheet.calendar</field>
<field name="view_mode">list,form</field>
</record>
<menuitem id="menu_cwf_attendance_attendance" name="CWF" parent="hr_attendance.menu_hr_attendance_root"
sequence="6" groups="hr_employee_extended.group_external_user,hr_attendance.group_hr_attendance_manager"/>
<menuitem id="menu_timesheet_calendar_form" name="CWF Timesheet Calendar" parent="menu_cwf_attendance_attendance"
action="cwf_timesheet.action_cwf_timesheet_calendar" groups="hr_attendance.group_hr_attendance_manager"/>
<record id="view_timesheet_form" model="ir.ui.view">
<field name="name">cwf.timesheet.form</field>
<field name="model">cwf.timesheet</field>
<field name="arch" type="xml">
<form string="Timesheet">
<header>
<button name="send_timesheet_update_email" string="Send Email" type="object"
invisible="status != 'draft'"/>
</header>
<sheet>
<div class="oe_title">
<label for="name"/>
<h1>
<field name="name" class="oe_inline" readonly="status != 'draft'"/>
</h1>
</div>
<group>
<!-- Section for Employee and Date Range -->
<group>
<field name="week_start_date" readonly="status != 'draft'"/>
<field name="week_end_date" readonly="status != 'draft'"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<record id="view_timesheet_list" model="ir.ui.view">
<field name="name">cwf.timesheet.list</field>
<field name="model">cwf.timesheet</field>
<field name="arch" type="xml">
<list>
<field name="name"/>
<field name="week_start_date"/>
<field name="week_end_date"/>
</list>
</field>
</record>
<record id="action_cwf_timesheet" model="ir.actions.act_window">
<field name="name">CWF Timesheet</field>
<field name="res_model">cwf.timesheet</field>
<field name="view_mode">list,form</field>
</record>
</odoo>

View File

@ -0,0 +1,158 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="view_cwf_weekly_timesheet_form" model="ir.ui.view">
<field name="name">cwf.weekly.timesheet.form</field>
<field name="model">cwf.weekly.timesheet</field>
<field name="arch" type="xml">
<form string="CWF Weekly Timesheet">
<header>
<button name="update_attendance" string="Update"
type="object" class="oe_highlight" invisible="status != 'draft'"/>
<button name="action_submit" string="Submit" type="object" confirm="Are you sure you want to submit?"
class="oe_highlight" invisible="status != 'draft'"/>
<field name="status" readonly="1" widget="statusbar"/>
</header>
<sheet>
<group>
<field name="month_id"/>
<field name="week_id" readonly="0" domain="['|',('start_month','=',month_id),('end_month','=',month_id)]"/>
<field name="employee_id" readonly="0" groups="hr_attendance.group_hr_attendance_manager"/>
<field name="employee_id" readonly="1" groups="hr_employee_extended.group_external_user"/>
<label for="week_start_date" string="Dates"/>
<div class="o_row">
<field name="week_start_date" widget="daterange" options="{'end_date_field.month()': 'week_end_date'}"/>
<field name="week_end_date" invisible="1"/>
</div>
</group>
<notebook>
<page string="Timesheet Lines">
<field name="cwf_timesheet_lines">
<list editable="bottom">
<field name="employee_id"/>
<field name="week_day"/>
<field name="check_in_date"/>
<field name="check_out_date"/>
<field name="state_type"/>
<!-- <button name="action_submit" string="Submit" type="object"-->
<!-- confirm="Are you sure you want to submit?" class="oe_highlight"/>-->
</list>
</field>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="view_cwf_weekly_timesheet_list" model="ir.ui.view">
<field name="name">cwf.weekly.timesheet.list</field>
<field name="model">cwf.weekly.timesheet</field>
<field name="arch" type="xml">
<list>
<field name="week_id"/>
<field name="employee_id"/>
<field name="status"/>
<!-- <button name="action_submit" string="Submit" type="object" confirm="Are you sure you want to submit?"-->
<!-- class="oe_highlight"/>-->
</list>
</field>
</record>
<record id="view_cwf_weekly_timesheet_search" model="ir.ui.view">
<field name="name">cwf.weekly.timesheet.search</field>
<field name="model">cwf.weekly.timesheet</field>
<field name="arch" type="xml">
<search>
<!-- Search by Week ID -->
<field name="week_id"/>
<!-- Search by Employee -->
<field name="employee_id"/>
<!-- Search by Status -->
<field name="status"/>
<!-- Optional: Add custom filters if needed -->
<filter string="Draft" name="filter_draft" domain="[('status','=','draft')]"/>
<filter string="Submitted" name="filter_submitted" domain="[('status','=','submit')]"/>
<group expand="0" string="Group By">
<filter string="Week" name="by_week_id" domain="[]" context="{'group_by':'week_id'}"/>
<separator/>
<filter string="Employee" name="by_employee_id" domain="[]" context="{'group_by':'employee_id'}"/>
<filter string="Status" name="state_type" domain="[]" context="{'group_by': 'status'}"/>
</group>
</search>
</field>
</record>
<record id="action_cwf_weekly_timesheet" model="ir.actions.act_window">
<field name="name">CWF Weekly Timesheet</field>
<field name="res_model">cwf.weekly.timesheet</field>
<field name="view_mode">list,form</field>
<field name="context">
{
"search_default_by_week_id": 1,
"search_default_by_employee_id": 2
}
</field>
<field name="search_view_id" ref="cwf_timesheet.view_cwf_weekly_timesheet_search"/>
</record>
<record id="view_cwf_timesheet_line_list" model="ir.ui.view">
<field name="name">cwf.timesheet.line.list</field>
<field name="model">cwf.timesheet.line</field>
<field name="arch" type="xml">
<list editable="bottom" create="0" delete="0" decoration-success="is_updated == True">
<field name="employee_id" readonly="1" force_save="1"/>
<field name="week_day" readonly="1" force_save="1"/>
<field name="check_in_date" readonly="is_updated == True"/>
<field name="check_out_date" readonly="is_updated == True"/>
<field name="state_type" readonly="is_updated == True"/>
<!-- <button name="action_submit" type="object" string="Submit" class="btn btn-outline-primary"-->
<!-- invisible="is_updated == True"/>-->
</list>
</field>
</record>
<record id="view_cwf_timesheet_line_search" model="ir.ui.view">
<field name="name">cwf.timesheet.line.search</field>
<field name="model">cwf.timesheet.line</field>
<field name="arch" type="xml">
<search string="Timesheets">
<field name="employee_id"/>
<field name="week_id"/>
<field name="week_day"/>
<field name="check_in_date"/>
<field name="check_out_date"/>
<field name="state_type"/>
<group expand="0" string="Group By">
<filter string="Employee" name="by_employee_id" domain="[]" context="{'group_by':'employee_id'}"/>
<separator/>
<filter string="Week" name="by_week_id" domain="[]" context="{'group_by':'week_id'}"/>
<filter string="Status" name="state_type" domain="[]" context="{'group_by': 'state_type'}"/>
</group>
</search>
</field>
</record>
<record id="action_cwf_timesheet_line" model="ir.actions.act_window">
<field name="name">CWF Timesheet Lines</field>
<field name="res_model">cwf.timesheet.line</field>
<field name="view_mode">list</field>
<field name="context">{"search_default_by_week_id": 1, "search_default_by_employee_id": 1}</field>
<field name="search_view_id" ref="cwf_timesheet.view_cwf_timesheet_line_search"/>
</record>
<menuitem id="menu_timesheet_form" name="CWF Weekly Timesheet "
parent="menu_cwf_attendance_attendance" action="action_cwf_weekly_timesheet" groups="hr_employee_extended.group_external_user,hr_attendance.group_hr_attendance_manager"/>
<menuitem id="menu_timesheet_form_line" name="CWF Timesheet Lines"
parent="menu_cwf_attendance_attendance" action="action_cwf_timesheet_line" groups="hr_employee_extended.group_external_user,hr_attendance.group_hr_attendance_manager"/>
</odoo>

View File

@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
from . import models
from . import controllers
from . import wizard

View File

@ -0,0 +1,94 @@
# -*- coding: utf-8 -*-
{
'name': "Documents",
'summary': "Collect, organize and share documents.",
'description': """
App to upload and manage your documents.
""",
'category': 'Productivity/Documents',
'sequence': 80,
'version': '1.4',
'application': True,
'website': 'https://www.ftprotech.in/',
# any module necessary for this one to work correctly
'depends': ['base', 'mail', 'portal', 'attachment_indexation', 'digest'],
# always loaded
'data': [
'security/security.xml',
'security/ir.model.access.csv',
'data/digest_data.xml',
'data/mail_template_data.xml',
'data/mail_activity_type_data.xml',
'data/documents_tag_data.xml',
'data/documents_document_data.xml',
'data/ir_config_parameter_data.xml',
'data/documents_tour.xml',
'views/res_config_settings_views.xml',
'views/res_partner_views.xml',
'views/documents_access_views.xml',
'views/documents_document_views.xml',
'views/documents_folder_views.xml',
'views/documents_tag_views.xml',
'views/mail_activity_views.xml',
'views/mail_activity_plan_views.xml',
'views/mail_alias_views.xml',
'views/documents_menu_views.xml',
'views/documents_templates_portal.xml',
'views/documents_templates_share.xml',
'wizard/documents_link_to_record_wizard_views.xml',
'wizard/documents_request_wizard_views.xml',
# Need the `ir.actions.act_window` to exist
'data/ir_actions_server_data.xml',
],
'demo': [
'demo/documents_document_demo.xml',
],
'license': 'OEEL-1',
'assets': {
'web.assets_backend': [
'documents/static/src/model/**/*',
'documents/static/src/scss/documents_views.scss',
'documents/static/src/scss/documents_kanban_view.scss',
'documents/static/src/attachments/**/*',
'documents/static/src/core/**/*',
'documents/static/src/js/**/*',
'documents/static/src/owl/**/*',
'documents/static/src/views/**/*',
('remove', 'documents/static/src/views/activity/**'),
('after', 'web/static/src/core/errors/error_dialogs.xml', 'documents/static/src/web/error_dialog/error_dialog_patch.xml'),
'documents/static/src/web/**/*',
'documents/static/src/components/**/*',
],
'web.assets_backend_lazy': [
'documents/static/src/views/activity/**',
],
'web._assets_primary_variables': [
'documents/static/src/scss/documents.variables.scss',
],
"web.dark_mode_variables": [
('before', 'documents/static/src/scss/documents.variables.scss', 'documents/static/src/scss/documents.variables.dark.scss'),
],
'documents.public_page_assets': [
('include', 'web._assets_helpers'),
('include', 'web._assets_backend_helpers'),
'web/static/src/scss/pre_variables.scss',
'web/static/lib/bootstrap/scss/_variables.scss',
'web/static/lib/bootstrap/scss/_variables-dark.scss',
'web/static/lib/bootstrap/scss/_maps.scss',
('include', 'web._assets_bootstrap_backend'),
'documents/static/src/scss/documents_public_pages.scss',
],
'documents.webclient': [
('include', 'web.assets_backend'),
# documents webclient overrides
'documents/static/src/portal_webclient/**/*',
'web/static/src/start.js',
],
}
}

View File

@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
from . import documents
from . import home
from . import portal

View File

@ -0,0 +1,695 @@
# -*- coding: utf-8 -*-
import base64
import io
import json
import logging
import pathlib
import zipfile
from collections import defaultdict
from contextlib import ExitStack
from http import HTTPStatus
from typing import NamedTuple
from werkzeug.exceptions import BadRequest, Forbidden
from odoo import conf, fields, http, _
from odoo.exceptions import MissingError
from odoo.http import request, content_disposition
from odoo.osv import expression
from odoo.tools import replace_exceptions, str2bool, consteq
from odoo.addons.mail.controllers.attachment import AttachmentController
logger = logging.getLogger(__name__)
class ShareRoute(http.Controller):
# util methods #################################################################################
def _max_content_length(self):
return request.env['documents.document'].get_document_max_upload_limit()
@classmethod
def _get_folder_children(cls, folder_sudo):
if request.env.user._is_public():
permission_domain = expression.AND([
[('is_access_via_link_hidden', '=', False)],
[('access_via_link', 'in', ('edit', 'view'))],
# public user cannot access a request, unless access_via_link='edit'
expression.OR([
[('access_via_link', '=', 'edit')],
[('type', '!=', 'binary')],
expression.OR([
[('attachment_id', '!=', False)],
[('shortcut_document_id.attachment_id', '!=', False)],
]),
])
])
else:
permission_domain = [('user_permission', '!=', 'none')] # needed for search in sudo
children_sudo = request.env['documents.document'].sudo().search(expression.AND([
[('folder_id', '=', folder_sudo.id)],
permission_domain,
]), order='name')
return children_sudo
@classmethod
def _from_access_token(cls, access_token, *, skip_log=False, follow_shortcut=True):
"""Get existing document with matching ``access_token``.
It returns an empty recordset when either:
* the document is not found ;
* the user cannot already access the document and the link
doesn't grant access ;
* the matching document is a shortcut but it is not allowed to
follow the shortcut.
Otherwise it returns the matching document in sudo.
A ``documents.access`` record is created/updated with the
current date unless the ``skip_log`` flag is set.
:param str access_token: the access token to the document record
:param bool skip_log: flag to prevent updating the record last
access date of internal users, useful to prevent silly
serialization errors, best used with read-only controllers.
:param bool follow_shortcut: flag to prevent returning the target
from a shortcut and instead return the shortcut itself.
"""
Doc = request.env['documents.document']
# Document record
try:
document_token, __, encoded_id = access_token.rpartition('o')
document_id = int(encoded_id, 16)
except ValueError:
return Doc
if not document_token or document_id < 1:
return Doc
document_sudo = Doc.browse(document_id).sudo()
if not document_sudo.document_token: # like exists() but prefetch
return Doc
if not request.env.user._is_internal() and not document_sudo.active:
return Doc
# Permissions
if not (
consteq(document_token, document_sudo.document_token)
and (document_sudo.user_permission != 'none'
or document_sudo.access_via_link != 'none')
):
return Doc
# Document access
skip_log = skip_log or request.env.user._is_public()
if not skip_log:
for doc_sudo in filter(bool, (document_sudo, document_sudo.shortcut_document_id)):
if access := request.env['documents.access'].sudo().search([
('partner_id', '=', request.env.user.partner_id.id),
('document_id', '=', doc_sudo.id),
]):
access.last_access_date = fields.Datetime.now()
else:
request.env['documents.access'].sudo().create([{
'document_id': doc_sudo.id,
'partner_id': request.env.user.partner_id.id,
'last_access_date': fields.Datetime.now(),
}])
# Shortcut
if follow_shortcut:
if target_sudo := document_sudo.shortcut_document_id:
if (target_sudo.user_permission != 'none'
or (target_sudo.access_via_link != 'none'
and not target_sudo.is_access_via_link_hidden)):
document_sudo = target_sudo
else:
document_sudo = Doc
# Extra validation step, to run with the target
if (
request.env.user._is_public()
and document_sudo.type == 'binary'
and not document_sudo.attachment_id
and document_sudo.access_via_link != 'edit'
):
# public cannot access a document request, unless access_via_link='edit'
return Doc
return document_sudo
def _make_zip(self, name, documents):
"""
Create a zip file in memory out of the given ``documents``,
recursively exploring the folders, get an HTTP response to
download that zip file.
:param str name: the name to give to the zip file
:param odoo.models.Model documents: documents to load in the ZIP
:return: a http response to download the zip file
"""
class Item(NamedTuple):
path: str
content: str
seen_folders = set() # because of shortcuts, we can have loops
# many documents can have the same name
seen_names = defaultdict(int)
def unique(pathname):
# files inside a zip can not have the same name
# (files in the documents application can)
seen_names[pathname] += 1
if seen_names[pathname] <= 1:
return pathname
ext = ''.join(pathlib.Path(pathname).suffixes)
return f'{pathname.removesuffix(ext)}-{seen_names[pathname]}{ext}'
def make_zip_item(document, folder):
if document.type == 'url':
raise ValueError("cannot create a zip item out of an url")
if document.type == 'folder':
# it is the ending slash that makes it appears as a
# folder inside the zip file.
return Item(unique(f'{folder.path}{document.name}') + '/', '')
try:
stream = self._documents_content_stream(document.shortcut_document_id or document)
except (ValueError, MissingError):
return None # skip
return Item(unique(f'{folder.path}{stream.download_name}'), stream.read())
def generate_zip_items(documents_sudo, folder):
documents_sudo = documents_sudo.sorted(lambda d: d.id)
yield from (
item
for doc in documents_sudo
if doc.type == 'binary' and (doc.shortcut_document_id or doc).attachment_id
if (item := make_zip_item(doc, folder)) is not None
)
for folder_sudo in documents_sudo:
if folder_sudo.type != 'folder' or folder_sudo in seen_folders:
continue
seen_folders.add(folder_sudo)
yield (sub_folder := make_zip_item(folder_sudo, folder))
for sub_document_sudo in self._get_folder_children(folder_sudo):
yield from generate_zip_items(sub_document_sudo, sub_folder)
# TODO: zip on-the-fly while streaming instead of loading the
# entire zip in memory and sending it all at once.
stream = io.BytesIO()
root_folder = Item('', '')
try:
with zipfile.ZipFile(stream, 'w') as doc_zip:
for (path, content) in generate_zip_items(documents, root_folder):
doc_zip.writestr(path, content, compress_type=zipfile.ZIP_DEFLATED)
except zipfile.BadZipfile:
logger.exception("BadZipfile exception")
content = stream.getvalue()
headers = [
('Content-Type', 'zip'),
('X-Content-Type-Options', 'nosniff'),
('Content-Length', len(content)),
('Content-Disposition', content_disposition(name))
]
return request.make_response(content, headers)
# Download & upload routes #####################################################################
@http.route('/documents/pdf_split', type='http', methods=['POST'], auth="user")
def pdf_split(self, new_files=None, ufile=None, archive=False, vals=None):
"""Used to split and/or merge pdf documents.
The data can come from different sources: multiple existing documents
(at least one must be provided) and any number of extra uploaded files.
:param new_files: the array that represents the new pdf structure:
[{
'name': 'New File Name',
'new_pages': [{
'old_file_type': 'document' or 'file',
'old_file_index': document_id or index in ufile,
'old_page_number': 5,
}],
}]
:param ufile: extra uploaded files that are not existing documents
:param archive: whether to archive the original documents
:param vals: values for the create of the new documents.
"""
vals = json.loads(vals)
new_files = json.loads(new_files)
# find original documents
document_ids = set()
for new_file in new_files:
for page in new_file['new_pages']:
if page['old_file_type'] == 'document':
document_ids.add(page['old_file_index'])
documents = request.env['documents.document'].browse(document_ids)
with ExitStack() as stack:
files = request.httprequest.files.getlist('ufile')
open_files = [stack.enter_context(io.BytesIO(file.read())) for file in files]
# merge together data from existing documents and from extra uploads
document_id_index_map = {}
current_index = len(open_files)
for document in documents:
open_files.append(stack.enter_context(io.BytesIO(base64.b64decode(document.datas))))
document_id_index_map[document.id] = current_index
current_index += 1
# update new_files structure with the new indices from documents
for new_file in new_files:
for page in new_file['new_pages']:
if page.pop('old_file_type') == 'document':
page['old_file_index'] = document_id_index_map[page['old_file_index']]
# apply the split/merge
new_documents = documents._pdf_split(new_files=new_files, open_files=open_files, vals=vals)
# archive original documents if needed
if archive == 'true':
documents.write({'active': False})
response = request.make_response(json.dumps(new_documents.ids), [('Content-Type', 'application/json')])
return response
@http.route('/documents/<access_token>', type='http', auth='public')
def documents_home(self, access_token):
document_sudo = self._from_access_token(access_token)
if not document_sudo:
Redirect = request.env['documents.redirect'].sudo()
if document_sudo := Redirect._get_redirection(access_token):
return request.redirect(
f'/documents/{document_sudo.access_token}',
HTTPStatus.MOVED_PERMANENTLY,
)
if request.env.user._is_public():
return self._documents_render_public_view(document_sudo)
elif request.env.user._is_portal():
return self._documents_render_portal_view(document_sudo)
else: # assume internal user
# Internal users use the /odoo/documents/<access_token> route
return request.redirect(
f'/odoo/documents/{access_token}',
HTTPStatus.TEMPORARY_REDIRECT,
)
def _documents_render_public_view(self, document_sudo):
target_sudo = document_sudo.shortcut_document_id
if (
target_sudo
and target_sudo.access_via_link != 'none'
and not target_sudo.is_access_via_link_hidden
):
return request.redirect(f'/odoo/documents/{target_sudo.access_token}')
if target_sudo or not document_sudo:
return request.render(
'documents.not_available', {'document': document_sudo}, status=404)
if document_sudo.type == 'url':
return request.redirect(
document_sudo.url, code=HTTPStatus.TEMPORARY_REDIRECT, local=False)
if document_sudo.type == 'binary' and document_sudo.attachment_id:
return request.render('documents.share_file', {'document': document_sudo})
if document_sudo.type == 'binary':
return request.render('documents.document_request_page', {'document': document_sudo})
if document_sudo.type == 'folder':
sub_documents_sudo = ShareRoute._get_folder_children(document_sudo)
return request.render('documents.public_folder_page', {
'folder': document_sudo,
'documents': sub_documents_sudo,
'subfolders': {
sub_folder_sudo.id: ShareRoute._get_folder_children(sub_folder_sudo)
for sub_folder_sudo in sub_documents_sudo
if sub_folder_sudo.type == 'folder'
}
})
else:
e = f"unknown document type {document_sudo.type}"
raise NotImplementedError(e)
def _documents_render_portal_view(self, document):
""" Render the portal version (stripped version of the backend Documents app). """
# We build the session information necessary for the web client to load
session_info = request.env['ir.http'].session_info()
mods = conf.server_wide_modules or []
lang = request.env.context.get('lang')
cache_hashes = {
"translations": request.env['ir.http'].get_web_translations_hash(mods, lang),
}
session_info.update(
cache_hashes=cache_hashes,
user_companies={
'current_company': request.env.company.id,
'allowed_companies': {
request.env.company.id: {
'id': request.env.company.id,
'name': request.env.company.name,
},
},
},
documents_init=self._documents_get_init_data(document, request.env.user),
)
return request.render(
'documents.document_portal_view',
{'session_info': session_info},
)
@classmethod
def _documents_get_init_data(cls, document, user):
""" Get initialization data to restore the interface on the selected document. """
if not document or not user:
return {}
document.ensure_one()
documents_init = {}
# If the user does not have access to the parent folder, we open it in the "SHARED" folder.
if document.type != 'folder':
parent = document.folder_id
shared_root = False if user.share else "SHARED" # Portal don't have 'Shared with me'
if parent:
documents_init['folder_id'] = parent.id if parent.user_permission in {'view', 'edit'} else shared_root
else:
documents_init['folder_id'] = (
"MY" if document.owner_id == user
else "COMPANY" if not user.share and (
document.owner_id == document.env.ref('base.user_root') or document.access_internal != 'none')
else shared_root
)
documents_init['document_id'] = document.id
target = document.shortcut_document_id or document
if document.type == 'binary' and target.attachment_id:
documents_init['open_preview'] = True
else:
documents_init['folder_id'] = document.id
return documents_init
@http.route('/documents/avatar/<access_token>',
type='http', auth='public', readonly=True)
def documents_avatar(self, access_token):
"""Show the avatar of the document's owner, or the avatar placeholder.
:param access_token: the access token to the document record
"""
partner_sudo = self._from_access_token(access_token, skip_log=True).owner_id.partner_id
return request.env['ir.binary']._get_image_stream_from(
partner_sudo, 'avatar_128', placeholder=partner_sudo._avatar_get_placeholder_path()
).get_response(as_attachment=False)
@http.route('/documents/content/<access_token>',
type='http', auth='public', readonly=True)
def documents_content(self, access_token, download=True):
"""Serve the file of the document.
:param access_token: the access token to the document record
:param download: whether to download the document on the user's
file system or to preview the document within the browser
"""
document_sudo = self._from_access_token(access_token, skip_log=True)
if not document_sudo:
raise request.not_found()
if document_sudo.type == 'url':
return request.redirect(
document_sudo.url, code=HTTPStatus.TEMPORARY_REDIRECT, local=False)
if document_sudo.type == 'folder':
return self._make_zip(
f'{document_sudo.name}.zip',
self._get_folder_children(document_sudo),
)
if document_sudo.type == 'binary':
if not document_sudo.attachment_id:
raise request.not_found()
with replace_exceptions(ValueError, by=BadRequest):
download = str2bool(download)
with replace_exceptions(ValueError, MissingError, by=request.not_found()):
stream = self._documents_content_stream(document_sudo)
return stream.get_response(as_attachment=download)
e = f"unknown document type {document_sudo.type!r}"
raise NotImplementedError(e)
def _documents_content_stream(self, document_sudo):
return request.env['ir.binary']._get_stream_from(document_sudo)
@http.route('/documents/redirect/<access_token>', type='http', auth='public', readonly=True)
def documents_redirect(self, access_token):
return request.redirect(f'/odoo/documents/{access_token}', HTTPStatus.MOVED_PERMANENTLY)
@http.route('/documents/touch/<access_token>', type='json', auth='user')
def documents_touch(self, access_token):
self._from_access_token(access_token)
return {}
@http.route(['/documents/thumbnail/<access_token>',
'/documents/thumbnail/<access_token>/<int:width>x<int:height>'],
type='http', auth='public', readonly=True)
def documents_thumbnail(self, access_token, width='0', height='0', unique=''):
"""Show the thumbnail of the document, or a placeholder.
:param access_token: the access token to the document record
:param width: resize the thumbnail to this maximum width
:param height: resize the thumbnail to this maximum height
:param unique: force storing the file in the browser cache, best
used with the checksum of the attachment
"""
with replace_exceptions(ValueError, by=BadRequest):
width = int(width)
height = int(height)
send_file_kwargs = {}
if unique:
send_file_kwargs['immutable'] = True
send_file_kwargs['max_age'] = http.STATIC_CACHE_LONG
document_sudo = self._from_access_token(access_token, skip_log=True)
return request.env['ir.binary']._get_image_stream_from(
document_sudo, 'thumbnail', width=width, height=height
).get_response(as_attachment=False, **send_file_kwargs)
@http.route(['/documents/document/<int:document_id>/update_thumbnail'], type='json', auth='user')
def documents_update_thumbnail(self, document_id, thumbnail):
"""Update the thumbnail of the document (after it has been generated by the browser).
We update the thumbnail in SUDO, after checking the read access, so it will work
if the user that generates the thumbnail is not the user who uploaded the document.
"""
document = request.env['documents.document'].browse(document_id)
document.check_access('read')
if document.thumbnail_status != 'client_generated':
return
document.sudo().write({
'thumbnail': thumbnail,
'thumbnail_status': 'present' if thumbnail else 'error',
})
@http.route(['/documents/zip'], type='http', auth='user')
def documents_zip(self, file_ids, zip_name, **kw):
"""Select many files / folders in the interface and click on download.
:param file_ids: if of the files to zip.
:param zip_name: name of the zip file.
"""
ids_list = [int(x) for x in file_ids.split(',')]
documents = request.env['documents.document'].browse(ids_list)
documents.check_access('read')
return self._make_zip(zip_name, documents)
@http.route([
'/document/download/all/<int:share_id>/<access_token>',
'/document/download/all/<access_token>'], type='http', auth='public')
def documents_download_all_legacy(self, access_token=None, share_id=None):
logger.warning("Deprecated since Odoo 18. Please access /documents/content/<access_token> instead.")
return request.redirect(f'/documents/content/{access_token}', HTTPStatus.MOVED_PERMANENTLY)
@http.route([
'/document/share/<int:share_id>/<token>',
'/document/share/<token>'], type='http', auth='public')
def share_portal(self, share_id=None, token=None):
logger.warning("Deprecated since Odoo 18. Please access /odoo/documents/<access_token> instead.")
return request.redirect(f'/odoo/documents/{token}', code=HTTPStatus.MOVED_PERMANENTLY)
@http.route(['/documents/upload/', '/documents/upload/<access_token>'],
type='http', auth='public', methods=['POST'],
max_content_length=_max_content_length)
def documents_upload(
self,
ufile,
access_token='',
owner_id='',
partner_id='',
res_id='',
res_model='',
):
"""
Replace an existing document or create new ones.
:param ufile: a list of multipart/form-data files.
:param access_token: the access token to a folder in which to
create new documents, or the access token to an existing
document where to upload/replace its attachment.
A falsy value means no folder_id and is allowed for
internal users to upload at the root of "My Drive".
:param owner_id, partner_id, res_id, res_model: field values
when creating new documents, for internal users only
"""
is_internal_user = request.env.user._is_internal()
if is_internal_user and not access_token:
document_sudo = request.env['documents.document'].sudo()
else:
document_sudo = self._from_access_token(access_token)
if (
not document_sudo
or (document_sudo.user_permission != 'edit'
and document_sudo.access_via_link != 'edit')
or document_sudo.type not in ('binary', 'folder')
):
raise request.not_found()
files = request.httprequest.files.getlist('ufile')
if not files:
raise BadRequest("missing files")
if len(files) > 1 and document_sudo.type not in (False, 'folder'):
raise BadRequest("cannot save multiple files inside a single document")
if is_internal_user:
with replace_exceptions(ValueError, by=BadRequest):
owner_id = int(owner_id) if owner_id else request.env.user.id
partner_id = int(partner_id) if partner_id else False
res_model = res_model or 'documents.document'
res_id = int(res_id) if res_id else False
elif owner_id or partner_id or res_id or res_model:
raise Forbidden("only internal users can provide field values")
else:
owner_id = document_sudo.owner_id.id if request.env.user.is_public else request.env.user.id
partner_id = False
res_model = 'documents.document'
res_id = False # replaced by the document's id
document_ids = self._documents_upload(
document_sudo, files, owner_id, partner_id, res_id, res_model)
if len(document_ids) == 1:
document_sudo = document_sudo.browse(document_ids)
if request.env.user._is_public():
return request.redirect(document_sudo.access_url)
else:
return request.make_json_response(document_ids)
def _documents_upload(self,
document_sudo, files, owner_id, partner_id, res_id, res_model):
""" Replace an existing document or upload a new one. """
is_internal_user = request.env.user._is_internal()
document_ids = []
AttachmentSudo = request.env['ir.attachment'].sudo(not is_internal_user)
if document_sudo.type == 'binary':
attachment_sudo = AttachmentSudo._from_request_file(
files[0], mimetype='TRUST' if is_internal_user else 'GUESS'
)
attachment_sudo.res_model = document_sudo.res_model
attachment_sudo.res_id = document_sudo.res_id
values = {'attachment_id': attachment_sudo.id}
if not document_sudo.attachment_id: # is a request
if document_sudo.access_via_link == 'edit':
values['access_via_link'] = 'view'
self._documents_upload_create_write(document_sudo, values)
document_ids.append(document_sudo.id)
else:
folder_sudo = document_sudo
for file in files:
document_sudo = self._documents_upload_create_write(folder_sudo, {
'attachment_id': AttachmentSudo._from_request_file(
file, mimetype='TRUST' if is_internal_user else 'GUESS'
).id,
'type': 'binary',
'access_via_link': 'none' if folder_sudo.access_via_link in (False, 'none') else 'view',
'folder_id': folder_sudo.id,
'owner_id': owner_id,
'partner_id': partner_id,
'res_model': res_model,
'res_id': res_id,
})
document_ids.append(document_sudo.id)
if folder_sudo.create_activity_option:
folder_sudo.browse(document_ids).documents_set_activity(
settings_record=folder_sudo)
return document_ids
def _documents_upload_create_write(self, document_sudo, vals):
"""
The actual function that either write vals on a binary document
or create a new document with vals inside a folder document.
"""
if document_sudo.type == 'binary':
document_sudo.write(vals)
else:
vals.setdefault('folder_id', document_sudo.id)
document_sudo = document_sudo.create(vals)
if not document_sudo.res_model:
document_sudo.res_model = 'documents.document'
if (
document_sudo.res_model == 'documents.document'
and not document_sudo.res_id
):
document_sudo.res_id = document_sudo.id
if (any(field_name in vals for field_name in [
'raw', 'datas', 'attachment_id'])):
document_sudo.message_post(body=_(
"Document uploaded by %(user)s",
user=request.env.user.name
))
return document_sudo
@http.route('/documents/upload_traceback', type='http', methods=['POST'], auth='user')
def documents_upload_traceback(self, ufile, max_content_length=1 << 20): # 1MiB
if not request.env.user._is_internal():
raise Forbidden()
folder_sudo = request.env.ref(
'documents.document_support_folder',
raise_if_not_found=False
).sudo()
if not folder_sudo or not folder_sudo.active:
raise request.not_found()
files = request.httprequest.files.getlist('ufile')
if not files:
raise BadRequest("missing files")
if len(files) > 1:
raise BadRequest("This route only accepts one file at a time.")
traceback_sudo = self._documents_upload_create_write(folder_sudo, {
'attachment_id': request.env['ir.attachment']._from_request_file(
files[0], mimetype='text/plain').id,
'type': 'binary',
'access_internal': 'none',
'access_via_link': 'view',
'folder_id': folder_sudo.id,
'owner_id': request.env.ref('base.user_root').id,
})
return request.make_json_response([traceback_sudo.access_url])
class DocumentsAttachmentController(AttachmentController):
@http.route()
def mail_attachment_upload(self, *args, **kw):
""" Override to prevent the creation of a document when uploading
an attachment from an activity already linked to a document."""
if kw.get('activity_id'):
document = request.env['documents.document'].search([('request_activity_id', '=', int(kw['activity_id']))])
if document:
request.update_context(no_document=True)
return super().mail_attachment_upload(*args, **kw)

View File

@ -0,0 +1,86 @@
from http import HTTPStatus
from urllib.parse import urlencode
from odoo.http import request, route
from odoo.addons.web.controllers import home as web_home
from odoo.addons.web.controllers.utils import ensure_db
from .documents import ShareRoute
class Home(web_home.Home):
def _web_client_readonly(self):
""" Force a read/write cursor for documents.access """
path = request.httprequest.path
if (
path.startswith('/odoo/documents')
and (request.httprequest.args.get('access_token') or path.removeprefix('/odoo/documents/'))
and request.session.uid
):
return False
return super()._web_client_readonly()
@route(readonly=_web_client_readonly)
def web_client(self, s_action=None, **kw):
""" Handle direct access to a document with a backend URL (/odoo/documents/<access_token>).
It redirects to the document either in:
- the backend if the user is logged and has access to the Documents module
- or a lightweight version of the backend if the user is logged and has not access
to the Document module but well to the documents.document model
- or the document portal otherwise
Goal: Allow to share directly the backend URL of a document.
"""
subpath = kw.get('subpath', '')
access_token = request.params.get('access_token') or subpath.removeprefix('documents/')
if not subpath.startswith('documents') or not access_token or '/' in access_token:
return super().web_client(s_action, **kw)
# This controller should be auth='public' but it actually is
# auth='none' for technical reasons (see super). Those three
# lines restore the public behavior.
ensure_db()
request.update_env(user=request.session.uid)
request.env['ir.http']._authenticate_explicit('public')
# Public/Portal users use the /documents/<access_token> route
if not request.env.user._is_internal():
return request.redirect(
f'/documents/{access_token}',
HTTPStatus.TEMPORARY_REDIRECT,
)
document_sudo = ShareRoute._from_access_token(access_token, follow_shortcut=False)
if not document_sudo:
Redirect = request.env['documents.redirect'].sudo()
if document_sudo := Redirect._get_redirection(access_token):
return request.redirect(
f'/odoo/documents/{document_sudo.access_token}',
HTTPStatus.MOVED_PERMANENTLY,
)
# We want (1) the webclient renders the webclient template and load
# the document action. We also want (2) the router rewrites
# /odoo/documents/<id> to /odoo/documents/<access-token> in the
# URL.
# We redirect on /web so this override does kicks in again,
# super() is loaded and renders the normal home template. We add
# custom fragments so we can load them inside the router and
# rewrite the URL.
query = {}
if request.session.debug:
query['debug'] = request.session.debug
fragment = {
'action': request.env.ref("documents.document_action").id,
'menu_id': request.env.ref('documents.menu_root').id,
'model': 'documents.document',
}
if document_sudo:
fragment.update({
f'documents_init_{key}': value
for key, value
in ShareRoute._documents_get_init_data(document_sudo, request.env.user).items()
})
return request.redirect(f'/web?{urlencode(query)}#{urlencode(fragment)}')

View File

@ -0,0 +1,19 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.addons.portal.controllers.portal import CustomerPortal
from odoo.exceptions import AccessError
from odoo.http import request
class DocumentCustomerPortal(CustomerPortal):
def _prepare_home_portal_values(self, counters):
values = super()._prepare_home_portal_values(counters)
if 'document_count' in counters:
Document = request.env['documents.document']
try:
count = Document.search_count([])
except AccessError:
count = 0
values['document_count'] = count
return values

View File

@ -0,0 +1,23 @@
<?xml version='1.0' encoding='utf-8'?>
<odoo>
<data>
<record id="digest_tip_documents_0" model="digest.tip">
<field name="name">Tip: Become a paperless company</field>
<field name="sequence">300</field>
<field name="group_id" ref="documents.group_documents_user" />
<field name="tip_description" type="html">
<div>
<t t-set="record" t-value="object.env['documents.document'].search([('alias_name', '!=', False), ('alias_domain_id', '!=', False)], limit=1)" />
<b class="tip_title">Tip: Become a paperless company</b>
<t t-if="record.alias_email">
<p class="tip_content">An easy way to process incoming mails is to configure your scanner to send PDFs to <t t-out="record.alias_email"/>. Scanned files will appear automatically in your workspace. Then, process your documents in bulk with the split tool: launch user defined actions, request a signature, convert to vendor bills with AI, etc.</p>
</t>
<t t-else="">
<p class="tip_content">An easy way to process incoming mails is to configure your scanner to send PDFs to your workspace email. Scanned files will appear automatically in your workspace. Then, process your documents in bulk with the split tool: launch user defined actions, request a signature, convert to vendor bills with AI, etc.</p>
</t>
<img src="/documents/static/src/img/documents-paperless.png" width="540" class="illustration_border" />
</div>
</field>
</record>
</data>
</odoo>

View File

@ -0,0 +1,43 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo><data noupdate="1">
<!-- Folders -->
<record id="document_internal_folder" model="documents.document" forcecreate="0">
<field name="type">folder</field>
<field name="access_internal">view</field>
<field name="name">Internal</field>
<field name="is_pinned_folder">True</field>
</record>
<record id="document_finance_folder" model="documents.document" forcecreate="0">
<field name="type">folder</field>
<field name="access_internal">edit</field>
<field name="name">Finance</field>
<field name="is_pinned_folder">True</field>
</record>
<record id="document_marketing_folder" model="documents.document" forcecreate="0">
<field name="type">folder</field>
<field name="name">Marketing</field>
<field name="access_internal">edit</field>
<field name="is_pinned_folder">True</field>
</record>
<record id="document_support_folder" model="documents.document" forcecreate="True">
<field name="name">Support</field>
<field name="type">folder</field>
<field name="access_internal">none</field>
<field name="access_via_link">none</field>
</record>
<!-- base data -->
<record id="documents_attachment_video_documents" model="documents.document" forcecreate="0">
<field name="name">Video: Odoo Documents</field>
<field name="type">url</field>
<field name="url">https://youtu.be/Ayab6wZ_U1A</field>
<field name="folder_id" ref="documents.document_internal_folder"/>
<field name="tag_ids" eval="[(6,0,[ref('documents.documents_tag_presentations'),
ref('documents.documents_tag_validated')])]"/>
</record>
</data></odoo>

View File

@ -0,0 +1,127 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo><data noupdate="1">
<!-- tags internal -->
<record id="documents_tag_draft" model="documents.tag" forcecreate="0">
<field name="name">Draft</field>
<field name="sequence">2</field>
</record>
<record id="documents_tag_inbox" model="documents.tag" forcecreate="0">
<field name="name">Inbox</field>
<field name="sequence">4</field>
</record>
<record id="documents_tag_to_validate" model="documents.tag" forcecreate="0">
<field name="name">To Validate</field>
<field name="sequence">6</field>
</record>
<record id="documents_tag_validated" model="documents.tag" forcecreate="0">
<field name="name">Validated</field>
<field name="sequence">8</field>
</record>
<record id="documents_tag_deprecated" model="documents.tag" forcecreate="0">
<field name="name">Deprecated</field>
<field name="sequence">10</field>
</record>
<record id="documents_tag_hr" model="documents.tag" forcecreate="0">
<field name="name">HR</field>
<field name="sequence">9</field>
</record>
<record id="documents_tag_sales" model="documents.tag" forcecreate="0">
<field name="name">Sales</field>
<field name="sequence">9</field>
</record>
<record id="documents_tag_legal" model="documents.tag" forcecreate="0">
<field name="name">Legal</field>
<field name="sequence">9</field>
</record>
<record id="documents_tag_other" model="documents.tag" forcecreate="0">
<field name="name">Other</field>
<field name="sequence">10</field>
</record>
<record id="documents_tag_presentations" model="documents.tag" forcecreate="0">
<field name="name">Presentations</field>
<field name="sequence">10</field>
</record>
<record id="documents_tag_contracts" model="documents.tag" forcecreate="0">
<field name="name">Contracts</field>
<field name="sequence">10</field>
</record>
<record id="documents_tag_project" model="documents.tag" forcecreate="0">
<field name="name">Project</field>
<field name="sequence">10</field>
</record>
<record id="documents_tag_text" model="documents.tag" forcecreate="0">
<field name="name">Text</field>
<field name="sequence">10</field>
</record>
<!-- tags finance -->
<record id="documents_tag_bill" model="documents.tag" forcecreate="0">
<field name="name">Bill</field>
<field name="sequence">4</field>
</record>
<record id="documents_tag_expense" model="documents.tag" forcecreate="0">
<field name="name">Expense</field>
<field name="sequence">5</field>
</record>
<record id="documents_tag_vat" model="documents.tag" forcecreate="0">
<field name="name">VAT</field>
<field name="sequence">6</field>
</record>
<record id="documents_tag_fiscal" model="documents.tag" forcecreate="0">
<field name="name">Fiscal</field>
<field name="sequence">7</field>
</record>
<record id="documents_tag_financial" model="documents.tag" forcecreate="0">
<field name="name">Financial</field>
<field name="sequence">8</field>
</record>
<record id="documents_tag_year_current" model="documents.tag" forcecreate="0">
<field name="name" eval="str(datetime.now().year)"/>
<field name="sequence">10</field>
</record>
<record id="documents_tag_year_previous" model="documents.tag" forcecreate="0">
<field name="name" eval="str(datetime.now().year-1)"/>
<field name="sequence">11</field>
</record>
<!-- tags marketing -->
<record id="documents_tag_ads" model="documents.tag" forcecreate="0">
<field name="name">Ads</field>
<field name="sequence">12</field>
</record>
<record id="documents_tag_brochures" model="documents.tag" forcecreate="0">
<field name="name">Brochures</field>
<field name="sequence">13</field>
</record>
<record id="documents_tag_images" model="documents.tag" forcecreate="0">
<field name="name">Images</field>
<field name="sequence">14</field>
</record>
<record id="documents_tag_videos" model="documents.tag" forcecreate="0">
<field name="name">Videos</field>
<field name="sequence">15</field>
</record>
</data></odoo>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="documents_tour" model="web_tour.tour">
<field name="name">documents_tour</field>
<field name="sequence">180</field>
<field name="rainbow_man_message"><![CDATA[
Wow... 6 documents processed in a few seconds, You're good.<br/>The tour is complete. Try uploading your own documents now.
]]></field>
</record>
</odoo>

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -0,0 +1,106 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="ir_actions_server_create_activity" model="ir.actions.server" forcecreate="0">
<field name="name">Create Activity</field>
<field name="model_id" ref="documents.model_documents_document"/>
<field name="groups_id" eval="[Command.link(ref('base.group_user'))]"/>
<field name="state">next_activity</field>
<field name="activity_type_id" ref="documents.mail_documents_activity_data_tv"/>
</record>
<record id="ir_actions_server_remove_activities" model="ir.actions.server" forcecreate="0">
<field name="name">Mark activities as completed</field>
<field name="model_id" ref="documents.model_documents_document"/>
<field name="groups_id" eval="[Command.link(ref('base.group_user'))]"/>
<field name="state">code</field>
<field name="code">
for record in records:
record.activity_ids.action_feedback(feedback="completed")
</field>
</record>
<record id="ir_actions_server_remove_tags" model="ir.actions.server" forcecreate="0">
<field name="name">Remove all tags</field>
<field name="model_id" ref="documents.model_documents_document"/>
<field name="groups_id" eval="[Command.link(ref('base.group_user'))]"/>
<field name="state">object_write</field>
<field name="update_m2m_operation">clear</field>
<field name="update_path">tag_ids</field>
</record>
<record id="ir_actions_server_send_to_finance" model="ir.actions.server" forcecreate="0">
<field name="name">Send To Finance</field>
<field name="model_id" ref="documents.model_documents_document"/>
<field name="groups_id" eval="[Command.link(ref('base.group_user'))]"/>
<field name="state">code</field>
<field name="code">
target = env.ref('documents.document_finance_folder', raise_if_not_found=False)
if target:
permissions = records.mapped('user_permission')
records.action_move_documents(target.id)
for record, permission in zip(records, permissions):
record.sudo().action_update_access_rights(partners={env.user.partner_id: (permission, None)})
action = {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'message': env._("%(nb_records)s document(s) sent to Finance", nb_records=len(records)),
'type': 'success',
}
}
</field>
</record>
<function model="documents.document" name="action_folder_embed_action" eval="[
ref('documents.document_internal_folder'),
ref('documents.ir_actions_server_send_to_finance'),
]"/>
<record id="ir_actions_server_tag_remove_inbox" model="ir.actions.server" forcecreate="0">
<field name="name">Remove Tag Inbox</field>
<field name="model_id" ref="documents.model_documents_document"/>
<field name="groups_id" eval="[Command.link(ref('base.group_user'))]"/>
<field name="update_path">tag_ids</field>
<field name="usage">ir_actions_server</field>
<field name="state">object_write</field>
<field name="update_m2m_operation">remove</field>
<field name="resource_ref" ref="documents.documents_tag_inbox"/>
</record>
<record id="ir_actions_server_tag_remove_to_validate" model="ir.actions.server" forcecreate="0">
<field name="name">Remove Tag To Validate</field>
<field name="model_id" ref="documents.model_documents_document"/>
<field name="groups_id" eval="[Command.link(ref('base.group_user'))]"/>
<field name="update_path">tag_ids</field>
<field name="usage">ir_actions_server</field>
<field name="state">object_write</field>
<field name="update_m2m_operation">remove</field>
<field name="resource_ref" ref="documents.documents_tag_to_validate"/>
</record>
<record id="ir_actions_server_tag_add_validated" model="ir.actions.server" forcecreate="0">
<field name="name">Add Tag Validated</field>
<field name="model_id" ref="documents.model_documents_document"/>
<field name="groups_id" eval="[Command.link(ref('base.group_user'))]"/>
<field name="update_path">tag_ids</field>
<field name="usage">ir_actions_server</field>
<field name="state">object_write</field>
<field name="update_m2m_operation">add</field>
<field name="resource_ref" ref="documents.documents_tag_validated"/>
</record>
<record id="ir_actions_server_tag_add_bill" model="ir.actions.server" forcecreate="0">
<field name="name">Add Tag Bill</field>
<field name="model_id" ref="documents.model_documents_document"/>
<field name="groups_id" eval="[Command.link(ref('base.group_user'))]"/>
<field name="update_path">tag_ids</field>
<field name="usage">ir_actions_server</field>
<field name="state">object_write</field>
<field name="update_m2m_operation">add</field>
<field name="resource_ref" ref="documents.documents_tag_bill"/>
</record>
</data>
</odoo>

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo><data noupdate="1">
<record id="ir_config_document_upload_limit" model="ir.config_parameter">
<field name="key">document.max_fileupload_size</field>
<field name="value">67000000</field>
</record>
<record id="ir_config_deletion_delay" model="ir.config_parameter">
<field name="key">documents.deletion_delay</field>
<field name="value">30</field>
</record>
</data></odoo>

View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo><data noupdate="1">
<record id="mail_documents_activity_data_Inbox" model="mail.activity.type">
<field name="name">Inbox</field>
<field name="res_model">documents.document</field>
</record>
<record id="mail_documents_activity_data_tv" model="mail.activity.type">
<field name="name">To validate</field>
<field name="res_model">documents.document</field>
</record>
<record id="mail_documents_activity_data_md" model="mail.activity.type">
<field name="name">Requested Document</field>
<field name="category">upload_file</field>
<field name="res_model">documents.document</field>
<field name="mail_template_ids" eval="[(4, ref('documents.mail_template_document_request_reminder'))]"/>
</record>
</data></odoo>

View File

@ -0,0 +1,252 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!--Email template -->
<record id="mail_template_document_request" model="mail.template">
<field name="name">Document: Document Request</field>
<field name="model_id" ref="model_documents_document"/>
<field name="subject">Document Request {{ object.name != False and ': '+ object.name or '' }}</field>
<field name="email_to" eval="False"/>
<field name="partner_to">{{ object.requestee_partner_id.id or '' }}</field>
<field name="description">Sent to partner when requesting a document from them</field>
<field name="body_html" type="html">
<table border="0" cellpadding="0" cellspacing="0" style="padding-top: 16px; background-color: #F1F1F1; font-family:Verdana, Arial,sans-serif; color: #454748; width: 100%; border-collapse:separate;"><tr><td align="center">
<table border="0" cellpadding="0" cellspacing="0" width="590" style="padding: 16px; background-color: white; color: #454748; border-collapse:separate;">
<tbody>
<!-- HEADER -->
<tr>
<td align="center" style="min-width: 590px;">
<table border="0" cellpadding="0" cellspacing="0" width="590" style="min-width: 590px; background-color: white; padding: 0px 8px 0px 8px; border-collapse:separate;">
<tr><td valign="middle">
<span style="font-size: 10px;">
Document Request: <br/>
<t t-if="object.name">
<span style="font-size: 20px; font-weight: bold;" t-out="object.name or ''">Inbox Financial</span>
</t>
</span><br/>
</td><td valign="middle" align="right" t-if="not object.create_uid.company_id.uses_default_logo">
<img t-attf-src="/logo.png?company={{ object.create_uid.company_id.id }}" style="padding: 0px; margin: 0px; height: auto; width: 80px;" t-att-alt="object.create_uid.company_id.name"/>
</td></tr>
<tr><td colspan="2" style="text-align:center;">
<hr width="100%" style="background-color:rgb(204,204,204);border:medium none;clear:both;display:block;font-size:0px;min-height:1px;line-height:0; margin: 16px 0px 16px 0px;"/>
</td></tr>
</table>
</td>
</tr>
<!-- CONTENT -->
<tr>
<td align="center" style="min-width: 590px;">
<table border="0" cellpadding="0" cellspacing="0" width="590" style="min-width: 590px; background-color: white; padding: 0px 8px 0px 8px; border-collapse:separate;">
<tr><td valign="top" style="font-size: 13px;">
<div>
Hello <t t-out="object.owner_id.name or ''">OdooBot</t>,
<br/><br/>
<t t-out="object.create_uid.name or ''">OdooBot</t> (<t t-out="object.create_uid.email or ''">odoobot@example.com</t>) asks you to provide the following document:
<br/><br/>
<center>
<div>
<t t-if="object.name">
<b t-out="object.name or ''">Inbox Financial</b>
</t>
</div>
<div>
<t t-if="object.request_activity_id.note">
<i t-out="object.request_activity_id.note or ''">Example of a note.</i>
</t>
</div>
<br/>
<div style="margin: 16px 0px 16px 0px;">
<a t-att-href="object.access_url"
style="background-color: #875A7B; padding: 20px 30px 20px 30px; text-decoration: none; color: #fff; border-radius: 5px; font-size:13px;">
Upload the requested document
</a>
</div>
</center><br/>
Please provide us with the missing document before <t t-out="object.request_activity_id.date_deadline">2021-05-17</t>.
<t t-if="user and user.signature">
<br/>
<t t-out="user.signature or ''">--<br/>Mitchell Admin</t>
<br/>
</t>
</div>
</td></tr>
<tr><td style="text-align:center;">
<hr width="100%" style="background-color:rgb(204,204,204);border:medium none;clear:both;display:block;font-size:0px;min-height:1px;line-height:0; margin: 16px 0px 16px 0px;"/>
</td></tr>
</table>
</td>
</tr>
<!-- FOOTER -->
<tr>
<td align="center" style="min-width: 590px;">
<table border="0" cellpadding="0" cellspacing="0" width="590" style="min-width: 590px; background-color: white; font-size: 11px; padding: 0px 8px 0px 8px; border-collapse:separate;">
<tr><td valign="middle" align="left">
<t t-out="object.create_uid.company_id.name or ''">YourCompany</t>
</td></tr>
<tr><td valign="middle" align="left" style="opacity: 0.7;">
<t t-out="object.create_uid.company_id.phone or ''">+1 650-123-4567</t>
<t t-if="object.create_uid.company_id.email">
| <a t-attf-href="'mailto:%s' % {{ object.create_uid.company_id.email }}" style="text-decoration:none; color: #454748;" t-out="object.create_uid.company_id.email or ''">info@yourcompany.com</a>
</t>
<t t-if="object.create_uid.company_id.website">
| <a t-attf-href="'%s' % {{ object.create_uid.company_id.website }}" style="text-decoration:none; color: #454748;" t-out="object.create_uid.company_id.website or ''">http://www.example.com</a>
</t>
</td></tr>
</table>
</td>
</tr>
</tbody>
</table>
</td></tr>
<!-- POWERED BY -->
<tr><td align="center" style="min-width: 590px;">
<table border="0" cellpadding="0" cellspacing="0" width="590" style="min-width: 590px; background-color: #F1F1F1; color: #454748; padding: 8px; border-collapse:separate;">
<tr><td style="text-align: center; font-size: 13px;">
Powered by <a target="_blank" href="https://www.ftprotech.in/app/documents" style="color: #875A7B;">Odoo Documents</a>
</td></tr>
</table>
</td></tr>
</table>
</field>
<field name="lang">{{ object.owner_id.lang }}</field>
<field name="auto_delete" eval="True"/>
</record>
<template id="mail_template_document_share">
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="background-color: white; padding: 0; border-collapse:separate; margin-bottom:13px;">
<tr><td valign="top">
<div style="margin: 0px; padding: 0px; font-size: 13px;">
<p style="margin: 0px; padding: 0px; font-size: 13px;">
<t t-if="record.name">
<t t-if="record.type == 'folder'">
<t t-out="user.name or ''"/> shared this folder with you: <t t-out="record.name"/>.<br/>
</t>
<t t-else="">
<t t-out="user.name or ''"/> shared this document with you: <t t-out="record.name"/>.<br/>
</t>
</t>
<t t-elif="record.type == 'folder'">
<t t-out="user.name or ''"/> shared a folder with you.<br/>
</t>
<t t-else="">
<t t-out="user.name or ''"/> shared a document with you.<br/>
</t>
<div t-if="message" style="color:#777; margin-top:13px;" t-out="message"/>
</p>
</div>
</td></tr>
</table>
</template>
<!-- Manual reminder; copy of document request template -->
<record id="mail_template_document_request_reminder" model="mail.template">
<field name="name">Document Request: Reminder</field>
<field name="model_id" ref="model_documents_document"/>
<field name="subject">Reminder to upload your document{{ object.name and ' : ' + object.name or '' }}</field>
<field name="email_to" eval="False"/>
<field name="partner_to">{{ object.requestee_partner_id.id or '' }}</field>
<field name="description">Set reminders in activities to notify users who didn't upload their requested document</field>
<field name="body_html" type="html">
<table border="0" cellpadding="0" cellspacing="0" style="padding-top: 16px; background-color: #F1F1F1; font-family:Verdana, Arial,sans-serif; color: #454748; width: 100%; border-collapse:separate;"><tr><td align="center">
<table border="0" cellpadding="0" cellspacing="0" width="590" style="padding: 16px; background-color: white; color: #454748; border-collapse:separate;">
<tbody>
<!-- HEADER -->
<tr>
<td align="center" style="min-width: 590px;">
<table border="0" cellpadding="0" cellspacing="0" width="590" style="min-width: 590px; background-color: white; padding: 0px 8px 0px 8px; border-collapse:separate;">
<tr><td valign="middle">
<span style="font-size: 10px;">
Document Request: <br/>
<t t-if="object.name">
<span style="font-size: 20px; font-weight: bold;" t-out="object.name or ''">Inbox Financial</span>
</t>
</span><br/>
</td><td valign="middle" align="right">
<img t-attf-src="/logo.png?company={{ object.create_uid.company_id.id }}" style="padding: 0px; margin: 0px; height: auto; width: 80px;" t-att-alt="object.create_uid.company_id.name"/>
</td></tr>
<tr><td colspan="2" style="text-align:center;">
<hr width="100%" style="background-color:rgb(204,204,204);border:medium none;clear:both;display:block;font-size:0px;min-height:1px;line-height:0; margin: 16px 0px 16px 0px;"/>
</td></tr>
</table>
</td>
</tr>
<!-- CONTENT -->
<tr>
<td align="center" style="min-width: 590px;">
<table border="0" cellpadding="0" cellspacing="0" width="590" style="min-width: 590px; background-color: white; padding: 0px 8px 0px 8px; border-collapse:separate;">
<tr><td valign="top" style="font-size: 13px;">
<div>
Hello <t t-out="object.owner_id.name or ''">OdooBot</t>,
<br/><br/>
This is a friendly reminder to upload your requested document:
<br/><br/>
<center>
<div>
<t t-if="object.name">
<b t-out="object.name or ''">Inbox Financial</b>
</t>
</div>
<div>
<t t-if="object.request_activity_id.note">
<i t-out="object.request_activity_id.note or ''">Example of a note.</i>
</t>
</div>
<br/>
<div style="margin: 16px 0px 16px 0px;">
<a t-att-href="object.access_url"
style="background-color: #875A7B; padding: 20px 30px 20px 30px; text-decoration: none; color: #fff; border-radius: 5px; font-size:13px;">
Upload the requested document
</a>
</div>
</center><br/>
Please provide us with the missing document before <t t-out="object.request_activity_id.date_deadline or ''">2021-05-17</t>.
<br/><br/>
Thank you,
<t t-if="user and user.signature">
<br/>
<t t-out="user.signature">--<br/>Mitchell Admin</t>
<br/>
</t>
</div>
</td></tr>
<tr><td style="text-align:center;">
<hr width="100%" style="background-color:rgb(204,204,204);border:medium none;clear:both;display:block;font-size:0px;min-height:1px;line-height:0; margin: 16px 0px 16px 0px;"/>
</td></tr>
</table>
</td>
</tr>
<!-- FOOTER -->
<tr>
<td align="center" style="min-width: 590px;">
<table border="0" cellpadding="0" cellspacing="0" width="590" style="min-width: 590px; background-color: white; font-size: 11px; padding: 0px 8px 0px 8px; border-collapse:separate;">
<tr><td valign="middle" align="left">
<t t-out="object.create_uid.company_id.name or ''">YourCompany</t>
</td></tr>
<tr><td valign="middle" align="left" style="opacity: 0.7;">
<t t-out="object.create_uid.company_id.phone or ''">+1 650-123-4567</t>
<t t-if="object.create_uid.company_id.email">
| <a t-attf-href="'mailto:%s' % {{ object.create_uid.company_id.email }}" style="text-decoration:none; color: #454748;" t-out="object.create_uid.company_id.email">info@yourcompany.com</a>
</t>
<t t-if="object.create_uid.company_id.website">
| <a t-att-href="object.create_uid.company_id.website" style="text-decoration:none; color: #454748;" t-out="object.create_uid.company_id.website">http://www.example.com</a>
</t>
</td></tr>
</table>
</td>
</tr>
</tbody>
</table>
</td></tr>
<!-- POWERED BY -->
<tr><td align="center" style="min-width: 590px;">
<table border="0" cellpadding="0" cellspacing="0" width="590" style="min-width: 590px; background-color: #F1F1F1; color: #454748; padding: 8px; border-collapse:separate;">
<tr><td style="text-align: center; font-size: 13px;">
Powered by <a target="_blank" href="https://www.ftprotech.in/app/documents" style="color: #875A7B;">Odoo Documents</a>
</td></tr>
</table>
</td></tr>
</table>
</field>
</record>
</data>
</odoo>

View File

@ -0,0 +1,122 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="base.user_demo" model="res.users">
<field name="groups_id" eval="[(3, ref('documents.group_documents_manager'))]"/>
</record>
<!-- folders -->
<record id="document_marketing_brand1_folder" model="documents.document" forcecreate="0">
<field name="type">folder</field>
<field name="folder_id" ref="document_marketing_folder"/>
<field name="access_internal">edit</field>
<field name="name">Brand 1</field>
</record>
<record id="document_marketing_brand1_shared_folder" model="documents.document" forcecreate="0">
<field name="type">folder</field>
<field name="folder_id" ref="document_marketing_brand1_folder"/>
<field name="access_internal">edit</field>
<field name="access_via_link">view</field>
<field name="name">Shared</field>
</record>
<record id="document_marketing_brand2_folder" model="documents.document" forcecreate="0">
<field name="type">folder</field>
<field name="folder_id" ref="document_marketing_folder"/>
<field name="access_internal">edit</field>
<field name="name">Brand 2</field>
</record>
<!-- internal -->
<record id="documents_data_multi_pdf_document" model="documents.document" forcecreate="0">
<field name="name">Mails_inbox.pdf</field>
<field name="datas" type="base64" file="documents/data/files/Mails_inbox.pdf"/>
<field name="folder_id" ref="documents.document_internal_folder"/>
<field name="access_internal">view</field>
<field name="tag_ids" eval="[(6,0,[ref('documents.documents_tag_inbox')])]"/>
</record>
<record id="documents_image_city_document" model="documents.document" forcecreate="0">
<field name="name">city.jpg</field>
<field name="datas" type="base64" file="documents/demo/files/city.jpg"/>
<field name="folder_id" ref="documents.document_internal_folder"/>
<field name="access_internal">view</field>
</record>
<record id="documents_image_mail_document" model="documents.document" forcecreate="0">
<field name="name">mail.png</field>
<field name="datas" type="base64" file="documents/data/files/mail.png"/>
<field name="folder_id" ref="documents.document_internal_folder"/>
<field name="access_internal">view</field>
<field name="tag_ids" eval="[(6,0,[ref('documents.documents_tag_inbox')])]"/>
</record>
<!-- The thumbnail is added after -->
<record id="documents_image_mail_document" model="documents.document" forcecreate="0">
<field name="thumbnail" type="base64" file="documents/data/files/mail_thumbnail.png"/>
</record>
<record id="documents_image_people_document" model="documents.document" forcecreate="0">
<field name="name">people.jpg</field>
<field name="datas" type="base64" file="documents/demo/files/people.jpg"/>
<field name="folder_id" ref="documents.document_internal_folder"/>
<field name="access_internal">view</field>
</record>
<!-- finance -->
<record id="documents_vendor_bill_inv_007" model="documents.document" forcecreate="0">
<field name="name">Invoice-INV_2018_0007.pdf</field>
<field name="datas" type="base64" file="documents/demo/files/Invoice2018_0007.pdf"/>
<field name="folder_id" ref="documents.document_finance_folder"/>
<field name="access_internal">edit</field>
<field name="tag_ids" eval="[(6,0,[ref('documents.documents_tag_validated')])]"/>
</record>
<record id="documents_vendor_bill_extract_azure_interior_document" model="documents.document" forcecreate="0">
<field name="name">invoice Azure Interior.pdf</field>
<field name="datas" type="base64" file="documents/demo/files/invoice_azure_interior.pdf"/>
<field name="folder_id" ref="documents.document_finance_folder"/>
<field name="access_internal">edit</field>
<field name="tag_ids" eval="[(6,0,[ref('documents.documents_tag_to_validate')])]"/>
</record>
<record id="documents_vendor_bill_extract_open_value_document" model="documents.document" forcecreate="0">
<field name="name">invoice OpenValue.pdf</field>
<field name="datas" type="base64" file="documents/demo/files/invoice_openvalue.pdf"/>
<field name="folder_id" ref="documents.document_finance_folder"/>
<field name="access_internal">edit</field>
<field name="tag_ids" eval="[(6,0,[ref('documents.documents_tag_inbox')])]"/>
</record>
<record id="documents_data_comercial_tenancy_agreement" model="documents.document" forcecreate="0">
<field name="name">Commercial-Tenancy-Agreement.pdf</field>
<field name="datas" type="base64" file="documents/demo/files/Commercial-Tenancy-Agreement.pdf"/>
<field name="folder_id" ref="documents.document_finance_folder"/>
<field name="access_internal">edit</field>
<field name="tag_ids" eval="[(6,0,[ref('documents.documents_tag_inbox')])]"/>
</record>
<!-- marketing -->
<record id="documents_image_La_landscape_document" model="documents.document" forcecreate="0">
<field name="name">LA landscape.jpg</field>
<field name="datas" type="base64" file="documents/demo/files/la.jpg"/>
<field name="folder_id" ref="documents.document_marketing_brand1_folder"/>
<field name="access_internal">edit</field>
<field name="tag_ids" eval="[(6,0,[ref('documents.documents_tag_images')])]"/>
</record>
<record id="documents_attachment_sorry_netsuite_document" model="documents.document" forcecreate="0">
<field name="name">Sorry Netsuite.jpg</field>
<field name="datas" type="base64" file="documents/demo/files/sorry_netsuite.jpg"/>
<field name="folder_id" ref="documents.document_marketing_brand1_shared_folder"/>
<field name="tag_ids" eval="[(6,0,[ref('documents.documents_tag_ads')])]"/>
<field name="access_internal">edit</field>
<field name="access_via_link">view</field>
</record>
</data>
</odoo>

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,23 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
# mixin
from . import documents_unlink_mixin
from . import documents_mixin
# documents
from . import documents_access
from . import documents_document
from . import documents_redirect
from . import documents_tag
# orm
from . import ir_attachment
from . import ir_binary
# inherit
from . import mail_activity
from . import mail_activity_type
from . import res_partner
from . import res_users
from . import res_config_settings

View File

@ -0,0 +1,41 @@
from odoo import _, api, fields, models
from odoo.exceptions import AccessError
class DocumentAccess(models.Model):
_name = 'documents.access'
_description = 'Document / Partner'
_log_access = False
document_id = fields.Many2one('documents.document', required=True, ondelete='cascade')
partner_id = fields.Many2one('res.partner', required=True, ondelete='cascade', index=True)
role = fields.Selection(
[('view', 'Viewer'), ('edit', 'Editor')],
string='Role', required=False, index=True)
last_access_date = fields.Datetime('Last Accessed On', required=False)
expiration_date = fields.Datetime('Expiration', index=True)
_sql_constraints = [
('unique_document_access_partner', 'unique(document_id, partner_id)',
'This partner is already set on this document.'),
('role_or_last_access_date', 'check (role IS NOT NULL or last_access_date IS NOT NULL)',
'NULL roles must have a set last_access_date'),
]
def _prepare_create_values(self, vals_list):
vals_list = super()._prepare_create_values(vals_list)
documents = self.env['documents.document'].browse(
[vals['document_id'] for vals in vals_list])
documents.check_access('write')
return vals_list
def write(self, vals):
if 'partner_id' in vals or 'document_id' in vals:
raise AccessError(_('Access documents and partners cannot be changed.'))
self.document_id.check_access('write')
return super().write(vals)
@api.autovacuum
def _gc_expired(self):
self.search([('expiration_date', '<=', fields.Datetime.now())], limit=1000).unlink()

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,143 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import Command, models
class DocumentMixin(models.AbstractModel):
"""
Inherit this mixin to automatically create a `documents.document` when
an `ir.attachment` is linked to a record and add the default values when
creating a document related to the model that inherits from this mixin.
Override this mixin's methods to specify an owner, a folder, tags or
access_rights for the document.
Note: this mixin can be disabled with the context variable "no_document=True".
"""
_name = 'documents.mixin'
_inherit = 'documents.unlink.mixin'
_description = "Documents creation mixin"
def _get_document_vals(self, attachment):
"""
Return values used to create a `documents.document`
"""
self.ensure_one()
document_vals = {}
if self._check_create_documents():
access_rights_vals = self._get_document_vals_access_rights()
if set(access_rights_vals) - {'access_via_link', 'access_internal', 'is_access_via_link_hidden'}:
raise ValueError("Invalid access right values")
document_vals = {
'attachment_id': attachment.id,
'name': attachment.name or self.display_name,
'folder_id': self._get_document_folder().id,
'owner_id': self._get_document_owner().id,
'partner_id': self._get_document_partner().id,
'tag_ids': [(6, 0, self._get_document_tags().ids)],
} | access_rights_vals
return document_vals
def _get_document_vals_access_rights(self):
""" Return access rights values to create a `documents.document`
In the default implementation, we give the minimal permission and rely on the propagation of the folder
permission but this method can be overridden to set more open rights.
Authorized fields: access_via_link, access_internal, is_access_via_link_hidden.
Note: access_ids are handled differently because when set, it prevents inheritance from the parent folder
(see specific document override).
"""
return {
'access_via_link': 'none',
'access_internal': 'none',
'is_access_via_link_hidden': True,
}
def _get_document_owner(self):
""" Return the owner value to create a `documents.document`
In the default implementation, we return OdooBot as owner to avoid giving full access to a user and to rely
instead on explicit access managed via `document.access` or via parent folder access inheritance but this
method can be overridden to for example give the ownership to the current user.
"""
return self.env.ref('base.user_root')
def _get_document_tags(self):
return self.env['documents.tag']
def _get_document_folder(self):
return self.env['documents.document']
def _get_document_partner(self):
return self.env['res.partner']
def _get_document_access_ids(self):
""" Add or remove members
:return boolean|list: list of tuple (partner, (role, expiration_date)) or False to avoid
inheriting members from parent folder.
"""
return []
def _check_create_documents(self):
return bool(self and self._get_document_folder())
def _prepare_document_create_values_for_linked_records(
self, res_model, vals_list, pre_vals_list):
""" Set default value defined on the document mixin implementation of the related record if there are not
explicitly set.
:param str res_model: model referenced by the documents to consider
:param list[dict] vals_list: list of values
:param list[dict] pre_vals_list: list of values before _prepare_create_values (no permission inherited yet)
Note:
- This method doesn't override existing values (permission, owner, ...).
- The related record res_model must inherit from DocumentMixin
"""
if self._name != res_model:
raise ValueError(f'Invalid model {res_model} (expected {self._name})')
related_record_by_id = self.env[res_model].browse([
res_id for vals in vals_list if (res_id := vals.get('res_id'))]).grouped('id')
for vals, pre_vals in zip(vals_list, pre_vals_list):
if not vals.get('res_id'):
continue
related_record = related_record_by_id.get(vals['res_id'])
vals.update(
{
'owner_id': pre_vals.get('owner_id', related_record._get_document_owner().id),
'partner_id': pre_vals.get('partner_id', related_record._get_document_partner().id),
'tag_ids': pre_vals.get('tag_ids', [(6, 0, related_record._get_document_tags().ids)]),
} | {
key: value
for key, value in related_record._get_document_vals_access_rights().items()
if key not in pre_vals
})
if 'access_ids' in pre_vals:
continue
access_ids = vals.get('access_ids') or []
partner_with_access = {access[2]['partner_id'] for access in access_ids} # list of Command.create tuples
related_document_access = related_record._get_document_access_ids()
if related_document_access is False:
# Keep logs but remove members
access_ids = [a for a in access_ids if not a[2].get('role')]
else:
accesses_to_add = [
(partner, access)
for partner, access in related_record._get_document_access_ids()
if partner.id not in partner_with_access
]
if accesses_to_add:
access_ids.extend(
Command.create({
'partner_id': partner.id,
'role': role,
'expiration_date': expiration_date,
})
for partner, (role, expiration_date) in accesses_to_add
)
vals['access_ids'] = access_ids
return vals_list

View File

@ -0,0 +1,29 @@
from odoo import api, fields, models
class DocumentRedirect(models.Model):
"""Model used to keep the old links valid after the 18.0 migration.
Do *NOT* use that model or inherit from it, it will be removed in the future.
"""
_name = "documents.redirect"
_description = "Document Redirect"
_log_access = False
access_token = fields.Char(required=True, index="btree")
document_id = fields.Many2one("documents.document", ondelete="cascade")
@api.model
def _get_redirection(self, access_token):
"""Redirect to the right document, only if its access is view.
We won't redirect if the access is not "view" to not give write access
if the permission has been changed on the document (or to not give the
token if the access is "none").
"""
return self.search(
# do not give write access for old token
[("access_token", "=", access_token), ('document_id.access_via_link', '=', 'view')],
limit=1,
).document_id

View File

@ -0,0 +1,48 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from random import randint
from odoo import _, api, models, fields
from odoo.exceptions import UserError
class Tags(models.Model):
_name = "documents.tag"
_description = "Tag"
_order = "sequence, name"
@api.model
def _get_default_color(self):
return randint(1, 11)
name = fields.Char(required=True, translate=True)
sequence = fields.Integer('Sequence', default=10)
color = fields.Integer('Color', default=_get_default_color)
tooltip = fields.Char(help="Text shown when hovering on this tag", string="Tooltip")
document_ids = fields.Many2many('documents.document', 'document_tag_rel')
_sql_constraints = [
('tag_name_unique', 'unique (name)', "Tag name already used"),
]
@api.model
def _get_tags(self, domain):
"""
fetches the tag and facet ids for the document selector (custom left sidebar of the kanban view)
"""
tags = self.env['documents.document'].search(domain).tag_ids
return [
{
'sequence': tag.sequence,
'id': tag.id,
'color': tag.color,
'__count': len(tag.document_ids)
} for tag in tags
]
@api.ondelete(at_uninstall=False)
def _unlink_except_used_in_server_action(self):
external_ids = self._get_external_ids()
if external_ids and self.env['ir.actions.server'].search_count([('resource_ref', 'in', external_ids)], limit=1):
raise UserError(_("You cannot delete tags used in server actions."))

View File

@ -0,0 +1,26 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class DocumentUnlinkMixin(models.AbstractModel):
"""Send the related documents to trash when the record is deleted."""
_name = 'documents.unlink.mixin'
_description = "Documents unlink mixin"
def unlink(self):
"""Prevent deletion of the attachments / documents and send them to the trash instead."""
documents = self.env['documents.document'].search([
('res_model', '=', self._name),
('res_id', 'in', self.ids),
('active', '=', True),
])
for document in documents:
document.write({
'res_model': 'documents.document',
'res_id': document.id,
'active': False,
})
return super().unlink()

View File

@ -0,0 +1,87 @@
# -*- coding: utf-8 -*-
import base64
import io
from odoo import models, api
from odoo.tools.pdf import PdfFileWriter, PdfFileReader
class IrAttachment(models.Model):
_inherit = ['ir.attachment']
@api.model
def _pdf_split(self, new_files=None, open_files=None):
"""Creates and returns new pdf attachments based on existing data.
:param new_files: the array that represents the new pdf structure:
[{
'name': 'New File Name',
'new_pages': [{
'old_file_index': 7,
'old_page_number': 5,
}],
}]
:param open_files: array of open file objects.
:returns: the new PDF attachments
"""
vals_list = []
pdf_from_files = [PdfFileReader(open_file, strict=False) for open_file in open_files]
for new_file in new_files:
output = PdfFileWriter()
for page in new_file['new_pages']:
input_pdf = pdf_from_files[int(page['old_file_index'])]
page_index = page['old_page_number'] - 1
output.addPage(input_pdf.getPage(page_index))
with io.BytesIO() as stream:
output.write(stream)
vals_list.append({
'name': new_file['name'] + ".pdf",
'datas': base64.b64encode(stream.getvalue()),
})
return self.create(vals_list)
def _create_document(self, vals):
"""
Implemented by bridge modules that create new documents if attachments are linked to
their business models.
:param vals: the create/write dictionary of ir attachment
:return True if new documents are created
"""
# Special case for documents
if vals.get('res_model') == 'documents.document' and vals.get('res_id'):
document = self.env['documents.document'].browse(vals['res_id'])
if document.exists() and not document.attachment_id:
document.attachment_id = self[0].id
return False
# Generic case for all other models
res_model = vals.get('res_model')
res_id = vals.get('res_id')
model = self.env.get(res_model)
if model is not None and res_id and issubclass(self.pool[res_model], self.pool['documents.mixin']):
vals_list = [
model.browse(res_id)._get_document_vals(attachment)
for attachment in self
if not attachment.res_field and model.browse(res_id)._check_create_documents()
]
vals_list = [vals for vals in vals_list if vals] # Remove empty values
self.env['documents.document'].create(vals_list)
return True
return False
@api.model_create_multi
def create(self, vals_list):
attachments = super().create(vals_list)
for attachment, vals in zip(attachments, vals_list):
# the context can indicate that this new attachment is created from documents, and therefore
# doesn't need a new document to contain it.
if not self._context.get('no_document') and not attachment.res_field:
attachment.sudo()._create_document(dict(vals, res_model=attachment.res_model, res_id=attachment.res_id))
return attachments
def write(self, vals):
if not self._context.get('no_document'):
self.filtered(lambda a: not (vals.get('res_field') or a.res_field)).sudo()._create_document(vals)
return super(IrAttachment, self).write(vals)

View File

@ -0,0 +1,31 @@
from os.path import splitext
from odoo import models
class IrBinary(models.AbstractModel):
_inherit = 'ir.binary'
def _record_to_stream(self, record, field_name):
if record._name == 'documents.document' and field_name in ('raw', 'datas', 'db_datas'):
# Read access to document give implicit read access to the attachment
return super()._record_to_stream(record.attachment_id.sudo(), field_name)
return super()._record_to_stream(record, field_name)
def _get_stream_from(
self, record, field_name='raw', filename=None, filename_field='name', mimetype=None,
default_mimetype='application/octet-stream',
):
# skip magic detection of the file extension when it is provided
if (record._name == 'documents.document'
and filename is None
and record.file_extension
):
name, extension = splitext(record.name)
if extension == f'.{record.file_extension}':
filename = record.name
else:
filename = f'{name}.{record.file_extension}'
return super()._get_stream_from(
record, field_name, filename, filename_field, mimetype, default_mimetype)

View File

@ -0,0 +1,99 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import datetime
from odoo import api, models, fields, _
from odoo.osv import expression
class MailActivity(models.Model):
_inherit = 'mail.activity'
def _prepare_next_activity_values(self):
vals = super()._prepare_next_activity_values()
current_activity_type = self.activity_type_id
next_activity_type = current_activity_type.triggered_next_type_id
if current_activity_type.category == 'upload_file' and self.res_model == 'documents.document' and next_activity_type.category == 'upload_file':
existing_document = self.env['documents.document'].search([('request_activity_id', '=', self.id)], limit=1)
if 'summary' not in vals:
vals['summary'] = self.summary or _('Upload file request')
new_doc_request = self.env['documents.document'].create({
'owner_id': existing_document.owner_id.id,
'folder_id': next_activity_type.folder_id.id if next_activity_type.folder_id else existing_document.folder_id.id,
'tag_ids': [(6, 0, next_activity_type.tag_ids.ids)],
'name': vals['summary'],
})
vals['res_id'] = new_doc_request.id
return vals
def _action_done(self, feedback=False, attachment_ids=None):
if not self:
return super()._action_done(feedback=feedback, attachment_ids=attachment_ids)
documents = self.env['documents.document'].search([('request_activity_id', 'in', self.ids)])
document_without_attachment = documents.filtered(lambda d: not d.attachment_id)
if document_without_attachment and not feedback:
feedback = _("Document Request: %(name)s Uploaded by: %(user)s",
name=documents[0].name, user=self.env.user.name)
messages, next_activities = super(MailActivity, self.with_context(no_document=True))._action_done(
feedback=feedback, attachment_ids=attachment_ids)
# Downgrade access link role from edit to view if necessary (if the requestee didn't have a user at the request
# time, we previously granted him edit access by setting access_via_link to edit on the document).
documents.filtered(lambda document: document.access_via_link == 'edit').access_via_link = 'view'
# Remove request information on the document
documents.requestee_partner_id = False
documents.request_activity_id = False
# Attachment must be set after documents.request_activity_id is set to False to prevent document write to
# trigger an action_done.
if attachment_ids and document_without_attachment:
document_without_attachment.attachment_id = attachment_ids[0]
return messages, next_activities
@api.model_create_multi
def create(self, vals_list):
activities = super().create(vals_list)
upload_activities = activities.filtered(lambda act: act.activity_category == 'upload_file')
# link back documents and activities
upload_documents_activities = upload_activities.filtered(lambda act: act.res_model == 'documents.document')
if upload_documents_activities:
documents = self.env['documents.document'].browse(upload_documents_activities.mapped('res_id'))
for document, activity in zip(documents, upload_documents_activities):
if not document.request_activity_id:
document.request_activity_id = activity.id
# create underlying documents if related record is not a document
doc_vals = [{
'res_model': activity.res_model,
'res_id': activity.res_id,
'owner_id': activity.activity_type_id.default_user_id.id or self.env.user.id,
'folder_id': activity.activity_type_id.folder_id.id,
'tag_ids': [(6, 0, activity.activity_type_id.tag_ids.ids)],
'name': activity.summary or activity.res_name or 'upload file request',
'request_activity_id': activity.id,
} for activity in upload_activities.filtered(
lambda act: act.res_model != 'documents.document' and act.activity_type_id.folder_id
)]
if doc_vals:
self.env['documents.document'].sudo().create(doc_vals)
return activities
def write(self, vals):
write_result = super().write(vals)
if 'date_deadline' not in vals or not (
act_on_docs := self.filtered(lambda activity: activity.res_model == 'documents.document')):
return write_result
# Update expiration access of the requestee when updating the related request activity deadline
document_requestee_partner_ids = self.env['documents.document'].search_read([
('id', 'in', act_on_docs.mapped('res_id')),
('requestee_partner_id', '!=', False),
('request_activity_id', 'in', self.ids),
], ['requestee_partner_id'])
new_expiration_date = datetime.combine(self[0].date_deadline, datetime.max.time())
self.env['documents.access'].search(expression.OR([[
('document_id', '=', document_requestee_partner_id['id']),
('partner_id', '=', document_requestee_partner_id['requestee_partner_id'][0]),
('expiration_date', '<', new_expiration_date),
] for document_requestee_partner_id in document_requestee_partner_ids
])).expiration_date = new_expiration_date

View File

@ -0,0 +1,13 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models, fields
class MailActivityType(models.Model):
_inherit = "mail.activity.type"
tag_ids = fields.Many2many('documents.tag')
folder_id = fields.Many2one('documents.document',
domain="[('type', '=', 'folder'), ('shortcut_document_id', '=', False)]",
help="By defining a folder, the upload activities will generate a document")

View File

@ -0,0 +1,15 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
deletion_delay = fields.Integer(config_parameter="documents.deletion_delay", default=30,
help='Delay after permanent deletion of the document in the trash (days)')
_sql_constraints = [
('check_deletion_delay', 'CHECK(deletion_delay >= 0)', 'The deletion delay should be positive.'),
]

View File

@ -0,0 +1,48 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models, fields, _
from odoo.osv import expression
class Partner(models.Model):
_inherit = "res.partner"
document_count = fields.Integer('Document Count', compute='_compute_document_count')
def _compute_document_count(self):
read_group_var = self.env['documents.document']._read_group(
expression.AND([
[('partner_id', 'in', self.ids)],
[('type', '!=', 'folder')],
]),
groupby=['partner_id'],
aggregates=['__count'])
document_count_dict = {partner.id: count for partner, count in read_group_var}
for record in self:
record.document_count = document_count_dict.get(record.id, 0)
def action_see_documents(self):
self.ensure_one()
return {
'name': _('Documents'),
'domain': [('partner_id', '=', self.id)],
'res_model': 'documents.document',
'type': 'ir.actions.act_window',
'views': [(False, 'kanban')],
'view_mode': 'kanban',
'context': {
"default_partner_id": self.id,
"searchpanel_default_folder_id": False
},
}
def action_create_members_to_invite(self):
return {
'res_model': 'res.partner',
'target': 'new',
'type': 'ir.actions.act_window',
'view_id': self.env.ref('base.view_partner_simple_form').id,
'view_mode': 'form',
}

Some files were not shown because too many files have changed in this diff Show More